From d2b9f46b1af537e38cf157c09a813533c6c61e4d Mon Sep 17 00:00:00 2001 From: grabbit Date: Fri, 20 Feb 2026 01:11:50 +0800 Subject: [PATCH] feat: add Apply Config progress modal and fix stale PENDING health after reload - Add 4-step progress modal to config apply flow (validate, write, reload, services ready) - Poll SSE-updated data-share-health attributes to detect when services finish restarting - Fix stale health bug: recalculate health for affected shares based on actual mount success instead of preserving old health from before reload - Add modal overlay/card/step CSS matching the dark theme - Include connection refactor (multi-protocol support) and probe helpers from prior work Co-Authored-By: Claude Opus 4.6 --- src/cli/preset.rs | 9 +- src/cli/setup.rs | 158 ++++++---- src/cli/speed_test.rs | 9 +- src/cli/warmup.rs | 120 +++++--- src/config.rs | 405 ++++++++++++++++++------- src/config_diff.rs | 41 +-- src/rclone/config.rs | 128 ++++++-- src/rclone/mod.rs | 1 + src/rclone/mount.rs | 13 +- src/rclone/path.rs | 309 +++++++++++++++++++ src/rclone/probe.rs | 99 ++++-- src/services/nfs.rs | 10 +- src/services/samba.rs | 15 +- src/services/systemd.rs | 5 +- src/services/webdav.rs | 10 +- src/supervisor.rs | 43 ++- src/web/api.rs | 70 ++--- static/style.css | 53 ++++ templates/config.toml.default | 58 ++-- templates/web/partials/share_rows.html | 2 +- templates/web/tabs/config.html | 229 +++++++++++--- 21 files changed, 1369 insertions(+), 418 deletions(-) create mode 100644 src/rclone/path.rs diff --git a/src/cli/preset.rs b/src/cli/preset.rs index 4f163fa..38dd3ca 100644 --- a/src/cli/preset.rs +++ b/src/cli/preset.rs @@ -102,8 +102,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -214,8 +215,8 @@ mount_point = "/mnt/photos" let mut cfg = test_config(); Preset::Photographer.apply(&mut cfg); // Preset must never touch connection or share settings - assert_eq!(cfg.connections[0].nas_host, "10.0.0.1"); - assert_eq!(cfg.connections[0].nas_user, "admin"); + assert_eq!(cfg.connections[0].host, "10.0.0.1"); + assert_eq!(cfg.connections[0].user(), "admin"); assert_eq!(cfg.shares[0].name, "photos"); assert_eq!(cfg.shares[0].remote_path, "/photos"); } diff --git a/src/cli/setup.rs b/src/cli/setup.rs index d00b875..2b43f4c 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -3,17 +3,17 @@ //! Walks the user through NAS connection details, share paths, cache settings, //! and preset selection, then writes a ready-to-deploy config file. -use std::net::{SocketAddr, TcpStream}; use std::path::PathBuf; -use std::time::Duration; use anyhow::Result; use crate::cli::preset::Preset; use crate::config::{ - BandwidthConfig, CacheConfig, Config, ConnectionConfig, DirectoryCacheConfig, LogConfig, - ProtocolsConfig, ReadConfig, ShareConfig, WarmupConfig, WritebackConfig, + BandwidthConfig, CacheConfig, Config, ConnectionConfig, DirectoryCacheConfig, Endpoint, + LogConfig, ProtocolsConfig, ReadConfig, ShareConfig, SftpEndpoint, SmbEndpoint, WarmupConfig, + WritebackConfig, }; +use crate::rclone::probe::ConnParams; fn prompt(question: &str, default: Option<&str>) -> String { use std::io::Write; @@ -56,26 +56,48 @@ pub fn run(output: Option) -> Result<()> { anyhow::bail!("NAS hostname is required"); } - let nas_user = prompt("SFTP username", Some("admin")); + let protocol_choice = prompt("Protocol (1=SFTP, 2=SMB)", Some("1")); + let is_smb = protocol_choice == "2"; - let auth_method = prompt("Auth method (1=password, 2=SSH key)", Some("1")); - let (nas_pass, nas_key_file) = match auth_method.as_str() { - "2" => { - let key = prompt("SSH private key path", Some("/root/.ssh/id_rsa")); - (None, Some(key)) + let nas_user = if is_smb { + prompt("SMB username", Some("admin")) + } else { + prompt("SFTP username", Some("admin")) + }; + + let (nas_pass, nas_key_file, smb_domain, smb_share) = if is_smb { + let pass = prompt_password("SMB password (required)"); + if pass.is_empty() { + anyhow::bail!("SMB password is required"); } - _ => { - let pass = prompt_password("SFTP password"); - if pass.is_empty() { - anyhow::bail!("Password is required"); + let domain = prompt("SMB domain (optional, press Enter to skip)", Some("")); + let share = prompt("SMB share name (e.g. photos)", None); + if share.is_empty() { + anyhow::bail!("SMB share name is required"); + } + let domain_opt = if domain.is_empty() { None } else { Some(domain) }; + (Some(pass), None, domain_opt, Some(share)) + } else { + let auth_method = prompt("Auth method (1=password, 2=SSH key)", Some("1")); + match auth_method.as_str() { + "2" => { + let key = prompt("SSH private key path", Some("/root/.ssh/id_rsa")); + (None, Some(key), None, None) + } + _ => { + let pass = prompt_password("SFTP password"); + if pass.is_empty() { + anyhow::bail!("Password is required"); + } + (Some(pass), None, None, None) } - (Some(pass), None) } }; - let sftp_port: u16 = prompt("SFTP port", Some("22")) + let default_port = if is_smb { "445" } else { "22" }; + let conn_port: u16 = prompt("Port", Some(default_port)) .parse() - .unwrap_or(22); + .unwrap_or(if is_smb { 445 } else { 22 }); let conn_name = prompt("Connection name (alphanumeric)", Some("nas")); @@ -88,7 +110,11 @@ pub fn run(output: Option) -> Result<()> { let idx = shares.len() + 1; println!(); println!("Share #{idx}:"); - let remote_path = prompt(" NAS remote path (e.g. /volume1/photos)", None); + let remote_path = if is_smb { + prompt(" Remote path within share (e.g. / or /subfolder)", Some("/")) + } else { + prompt(" NAS remote path (e.g. /volume1/photos)", None) + }; if remote_path.is_empty() { if shares.is_empty() { println!(" At least one share is required."); @@ -143,15 +169,28 @@ pub fn run(output: Option) -> Result<()> { }; // Build config with defaults, then apply preset + let endpoint = if is_smb { + Endpoint::Smb(SmbEndpoint { + user: nas_user, + pass: nas_pass, + domain: smb_domain, + port: conn_port, + share: smb_share.unwrap(), + }) + } else { + Endpoint::Sftp(SftpEndpoint { + user: nas_user, + pass: nas_pass, + key_file: nas_key_file, + port: conn_port, + connections: 8, + }) + }; let mut config = Config { connections: vec![ConnectionConfig { name: conn_name.clone(), - nas_host: nas_host.clone(), - nas_user, - nas_pass, - nas_key_file, - sftp_port, - sftp_connections: 8, + host: nas_host.clone(), + endpoint, }], cache: CacheConfig { dir: PathBuf::from(&cache_dir), @@ -197,44 +236,43 @@ pub fn run(output: Option) -> Result<()> { preset.apply(&mut config); - // --- Connection test --- + // --- Connection test (rclone-based, validates credentials + share) --- println!(); - println!("Testing connectivity to {}:{}...", nas_host, sftp_port); - let addr_str = format!("{}:{}", nas_host, sftp_port); - match addr_str.parse::() { - Ok(addr) => match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) { - Ok(_) => println!(" Connection OK"), - Err(e) => anyhow::bail!( - "Cannot connect to {}:{} — check NAS host/port and ensure Tailscale is active.\nDetails: {}", - nas_host, sftp_port, e - ), - }, - Err(_) => { - // Might be a hostname — try resolving - use std::net::ToSocketAddrs; - match addr_str.to_socket_addrs() { - Ok(mut addrs) => { - if let Some(addr) = addrs.next() { - match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) { - Ok(_) => println!(" Connection OK"), - Err(e) => anyhow::bail!( - "Cannot connect to {}:{} — check NAS host/port and ensure Tailscale is active.\nDetails: {}", - nas_host, sftp_port, e - ), - } - } else { - anyhow::bail!( - "Cannot resolve hostname '{}' — check NAS host and ensure DNS is working.", - nas_host - ); - } - } - Err(e) => anyhow::bail!( - "Cannot resolve hostname '{}' — check NAS host and ensure DNS is working.\nDetails: {}", - nas_host, e - ), - } + println!("Testing connection to {}:{}...", nas_host, conn_port); + let test_params = if is_smb { + ConnParams::Smb { + host: nas_host.clone(), + user: config.connections[0].user().to_string(), + pass: config.connections[0].pass().map(String::from), + domain: match &config.connections[0].endpoint { + Endpoint::Smb(smb) => smb.domain.clone(), + _ => None, + }, + port: conn_port, + share: match &config.connections[0].endpoint { + Endpoint::Smb(smb) => smb.share.clone(), + _ => String::new(), + }, } + } else { + ConnParams::Sftp { + host: nas_host.clone(), + user: config.connections[0].user().to_string(), + pass: config.connections[0].pass().map(String::from), + key_file: match &config.connections[0].endpoint { + Endpoint::Sftp(sftp) => sftp.key_file.clone(), + _ => None, + }, + port: conn_port, + } + }; + match crate::rclone::probe::test_connection(&test_params) { + Ok(()) => println!(" Connection OK (rclone verified)"), + Err(e) => anyhow::bail!( + "Connection test failed for {}:{} — {}\n\ + Check host, credentials, and ensure rclone is installed.", + nas_host, conn_port, e + ), } // --- Write config --- diff --git a/src/cli/speed_test.rs b/src/cli/speed_test.rs index 7f4490c..b8b7650 100644 --- a/src/cli/speed_test.rs +++ b/src/cli/speed_test.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use crate::config::Config; use crate::rclone::config as rclone_config; +use crate::rclone::path as rclone_path; const TEST_SIZE: usize = 10 * 1024 * 1024; // 10 MiB @@ -15,10 +16,10 @@ pub fn run(config: &Config) -> Result<()> { let tmp_local = std::env::temp_dir().join("warpgate-speedtest"); // Use the first share's connection and remote_path for the speed test let share = &config.shares[0]; - let remote_path = format!( - "{}:{}/.warpgate-speedtest", - share.connection, share.remote_path - ); + let conn = config + .connection_for_share(share) + .context("Connection not found for first share")?; + let remote_path = rclone_path::rclone_remote_subpath(conn, share, ".warpgate-speedtest"); // Create a 10 MiB test file println!("Creating 10 MiB test file..."); diff --git a/src/cli/warmup.rs b/src/cli/warmup.rs index b862beb..7c59e15 100644 --- a/src/cli/warmup.rs +++ b/src/cli/warmup.rs @@ -14,14 +14,18 @@ use tracing::{debug, info, warn}; use crate::config::Config; use crate::daemon::{DaemonStatus, WarmupRuleState}; use crate::rclone::config as rclone_config; +use crate::rclone::path as rclone_path; pub fn run(config: &Config, share_name: &str, path: &str, newer_than: Option<&str>) -> Result<()> { let share = config .find_share(share_name) .with_context(|| format!("Share '{}' not found in config", share_name))?; + let conn = config + .connection_for_share(share) + .with_context(|| format!("Connection '{}' not found", share.connection))?; let warmup_path = share.mount_point.join(path); - let remote_src = format!("{}:{}/{}", share.connection, share.remote_path, path); + let remote_src = rclone_path::rclone_remote_subpath(conn, share, path); println!("Warming up: {remote_src}"); println!(" via mount: {}", warmup_path.display()); @@ -69,8 +73,9 @@ pub fn run(config: &Config, share_name: &str, path: &str, newer_than: Option<&st let mut skipped = 0usize; let mut errors = 0usize; + let cache_prefix = rclone_path::vfs_cache_prefix(conn, share); for file in &files { - if is_cached(config, &share.connection, &share.remote_path, path, file) { + if is_cached(config, &cache_prefix, path, file) { skipped += 1; continue; } @@ -117,9 +122,12 @@ pub fn run_tracked( let share = config .find_share(share_name) .with_context(|| format!("Share '{}' not found in config", share_name))?; + let conn = config + .connection_for_share(share) + .with_context(|| format!("Connection '{}' not found", share.connection))?; let warmup_path = share.mount_point.join(path); - let remote_src = format!("{}:{}/{}", share.connection, share.remote_path, path); + let remote_src = rclone_path::rclone_remote_subpath(conn, share, path); // Mark as Listing { @@ -214,6 +222,7 @@ pub fn run_tracked( } info!(share = %share_name, path = %path, total, "warmup: caching started"); + let cache_prefix = rclone_path::vfs_cache_prefix(conn, share); for file in &files { // Check shutdown / generation before each file if shutdown.load(Ordering::SeqCst) { @@ -226,7 +235,7 @@ pub fn run_tracked( } } - if is_cached(config, &share.connection, &share.remote_path, path, file) { + if is_cached(config, &cache_prefix, path, file) { let skipped = { let mut status = shared_status.write().unwrap(); if let Some(rs) = status.warmup.get_mut(rule_index) { @@ -304,13 +313,15 @@ pub fn run_tracked( } /// Check if a file is already in the rclone VFS cache. -fn is_cached(config: &Config, connection: &str, remote_path: &str, warmup_path: &str, relative_path: &str) -> bool { +/// +/// `cache_prefix` is the protocol-aware relative path from `rclone_path::vfs_cache_prefix`, +/// e.g. `nas/volume1/photos` (SFTP) or `office/photos/subfolder` (SMB). +fn is_cached(config: &Config, cache_prefix: &std::path::Path, warmup_path: &str, relative_path: &str) -> bool { let cache_path = config .cache .dir .join("vfs") - .join(connection) - .join(remote_path.trim_start_matches('/')) + .join(cache_prefix) .join(warmup_path) .join(relative_path); cache_path.exists() @@ -319,14 +330,16 @@ fn is_cached(config: &Config, connection: &str, remote_path: &str, warmup_path: #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; fn test_config() -> Config { toml::from_str( r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/warpgate-test-cache" @@ -347,56 +360,85 @@ mount_point = "/mnt/photos" .unwrap() } + fn smb_config() -> Config { + toml::from_str( + r#" +[[connections]] +name = "office" +host = "192.168.1.100" +protocol = "smb" +user = "admin" +pass = "secret" +share = "data" +port = 445 + +[cache] +dir = "/tmp/warpgate-test-cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "docs" +connection = "office" +remote_path = "/subfolder" +mount_point = "/mnt/docs" +"#, + ) + .unwrap() + } + #[test] fn test_is_cached_nonexistent_file() { let config = test_config(); - assert!(!is_cached(&config, "nas", "/photos", "2024", "IMG_001.jpg")); + let prefix = PathBuf::from("nas/photos"); + assert!(!is_cached(&config, &prefix, "2024", "IMG_001.jpg")); } #[test] fn test_is_cached_deep_path() { let config = test_config(); - assert!(!is_cached(&config, "nas", "/photos", "Images/2024/January", "photo.cr3")); + let prefix = PathBuf::from("nas/photos"); + assert!(!is_cached(&config, &prefix, "Images/2024/January", "photo.cr3")); } #[test] - fn test_is_cached_path_construction() { + fn test_is_cached_sftp_path_construction() { let config = test_config(); - let expected = std::path::PathBuf::from("/tmp/warpgate-test-cache") - .join("vfs") - .join("nas") - .join("photos") - .join("2024") - .join("IMG_001.jpg"); + let share = config.find_share("photos").unwrap(); + let conn = config.connection_for_share(share).unwrap(); + let prefix = rclone_path::vfs_cache_prefix(conn, share); - let cache_path = config - .cache - .dir - .join("vfs") - .join("nas") - .join("photos") - .join("2024") - .join("IMG_001.jpg"); + let expected = PathBuf::from("/tmp/warpgate-test-cache/vfs/nas/photos/2024/IMG_001.jpg"); + let cache_path = config.cache.dir.join("vfs").join(&prefix).join("2024").join("IMG_001.jpg"); + assert_eq!(cache_path, expected); + } + #[test] + fn test_is_cached_smb_path_construction() { + let config = smb_config(); + let share = config.find_share("docs").unwrap(); + let conn = config.connection_for_share(share).unwrap(); + let prefix = rclone_path::vfs_cache_prefix(conn, share); + + // SMB: includes share name "data" before "subfolder" + let expected = PathBuf::from("/tmp/warpgate-test-cache/vfs/office/data/subfolder/2024/file.jpg"); + let cache_path = config.cache.dir.join("vfs").join(&prefix).join("2024").join("file.jpg"); assert_eq!(cache_path, expected); } #[test] fn test_is_cached_remote_path_trimming() { let config = test_config(); + let share = config.find_share("photos").unwrap(); + let conn = config.connection_for_share(share).unwrap(); + let prefix = rclone_path::vfs_cache_prefix(conn, share); - let connection = "home"; - let remote_path = "/volume1/photos"; - let cache_path = config - .cache - .dir - .join("vfs") - .join(connection) - .join(remote_path.trim_start_matches('/')) - .join("2024") - .join("file.jpg"); - - assert!(cache_path.to_string_lossy().contains("home/volume1/photos")); - assert!(!cache_path.to_string_lossy().contains("home//volume1")); + let cache_path = config.cache.dir.join("vfs").join(&prefix).join("2024").join("file.jpg"); + assert!(cache_path.to_string_lossy().contains("nas/photos")); + assert!(!cache_path.to_string_lossy().contains("nas//photos")); } } diff --git a/src/config.rs b/src/config.rs index 38e5614..09e03b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,27 +68,102 @@ fn default_log_level() -> String { "info".into() } -/// SFTP connection to a remote NAS. +/// Connection to a remote NAS (SFTP or SMB). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ConnectionConfig { /// Unique name for this connection (used as rclone remote name). pub name: String, /// Remote NAS Tailscale IP or hostname. - pub nas_host: String, - /// SFTP username. - pub nas_user: String, - /// SFTP password (prefer key_file). + pub host: String, + /// Protocol-specific endpoint configuration. + #[serde(flatten)] + pub endpoint: Endpoint, +} + +/// Protocol-specific endpoint configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "protocol", rename_all = "lowercase")] +pub enum Endpoint { + Sftp(SftpEndpoint), + Smb(SmbEndpoint), +} + +/// SFTP endpoint configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SftpEndpoint { + pub user: String, #[serde(default)] - pub nas_pass: Option, - /// Path to SSH private key. + pub pass: Option, #[serde(default)] - pub nas_key_file: Option, - /// SFTP port. + pub key_file: Option, #[serde(default = "default_sftp_port")] - pub sftp_port: u16, - /// SFTP connection pool size. + pub port: u16, #[serde(default = "default_sftp_connections")] - pub sftp_connections: u32, + pub connections: u32, +} + +/// SMB endpoint configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SmbEndpoint { + pub user: String, + #[serde(default)] + pub pass: Option, + #[serde(default)] + pub domain: Option, + #[serde(default = "default_smb_port")] + pub port: u16, + /// Windows share name (used in rclone path, not in rclone.conf). + pub share: String, +} + +impl ConnectionConfig { + /// Protocol name string ("sftp" or "smb"). + pub fn protocol_name(&self) -> &str { + match &self.endpoint { + Endpoint::Sftp(_) => "sftp", + Endpoint::Smb(_) => "smb", + } + } + + /// Username for this connection. + pub fn user(&self) -> &str { + match &self.endpoint { + Endpoint::Sftp(e) => &e.user, + Endpoint::Smb(e) => &e.user, + } + } + + /// Password (if set). + pub fn pass(&self) -> Option<&str> { + match &self.endpoint { + Endpoint::Sftp(e) => e.pass.as_deref(), + Endpoint::Smb(e) => e.pass.as_deref(), + } + } + + /// Port number. + pub fn port(&self) -> u16 { + match &self.endpoint { + Endpoint::Sftp(e) => e.port, + Endpoint::Smb(e) => e.port, + } + } + + /// Get SFTP endpoint if this is an SFTP connection. + pub fn sftp(&self) -> Option<&SftpEndpoint> { + match &self.endpoint { + Endpoint::Sftp(e) => Some(e), + _ => None, + } + } + + /// Get SMB endpoint if this is an SMB connection. + pub fn smb(&self) -> Option<&SmbEndpoint> { + match &self.endpoint { + Endpoint::Smb(e) => Some(e), + _ => None, + } + } } /// SSD cache settings. @@ -326,6 +401,9 @@ fn default_sftp_port() -> u16 { fn default_sftp_connections() -> u32 { 8 } +fn default_smb_port() -> u16 { + 445 +} fn default_cache_max_size() -> String { "200G".into() } @@ -449,16 +527,32 @@ impl Config { for conn in &self.connections { writeln!(out, "[[connections]]").unwrap(); writeln!(out, "name = {:?}", conn.name).unwrap(); - writeln!(out, "nas_host = {:?}", conn.nas_host).unwrap(); - writeln!(out, "nas_user = {:?}", conn.nas_user).unwrap(); - if let Some(ref pass) = conn.nas_pass { - writeln!(out, "nas_pass = {:?}", pass).unwrap(); + writeln!(out, "host = {:?}", conn.host).unwrap(); + writeln!(out, "protocol = {:?}", conn.protocol_name()).unwrap(); + match &conn.endpoint { + Endpoint::Sftp(sftp) => { + writeln!(out, "user = {:?}", sftp.user).unwrap(); + if let Some(ref pass) = sftp.pass { + writeln!(out, "pass = {:?}", pass).unwrap(); + } + if let Some(ref key) = sftp.key_file { + writeln!(out, "key_file = {:?}", key).unwrap(); + } + writeln!(out, "port = {}", sftp.port).unwrap(); + writeln!(out, "connections = {}", sftp.connections).unwrap(); + } + Endpoint::Smb(smb) => { + writeln!(out, "user = {:?}", smb.user).unwrap(); + if let Some(ref pass) = smb.pass { + writeln!(out, "pass = {:?}", pass).unwrap(); + } + if let Some(ref domain) = smb.domain { + writeln!(out, "domain = {:?}", domain).unwrap(); + } + writeln!(out, "port = {}", smb.port).unwrap(); + writeln!(out, "share = {:?}", smb.share).unwrap(); + } } - if let Some(ref key) = conn.nas_key_file { - writeln!(out, "nas_key_file = {:?}", key).unwrap(); - } - writeln!(out, "sftp_port = {}", conn.sftp_port).unwrap(); - writeln!(out, "sftp_connections = {}", conn.sftp_connections).unwrap(); writeln!(out).unwrap(); } @@ -622,7 +716,7 @@ impl Config { anyhow::bail!("At least one [[connections]] entry is required"); } - // Validate connection names + // Validate connection names and protocol-specific fields let mut seen_conn_names = std::collections::HashSet::new(); for (i, conn) in self.connections.iter().enumerate() { if conn.name.is_empty() { @@ -642,6 +736,35 @@ impl Config { conn.name ); } + if conn.host.is_empty() { + anyhow::bail!("connections[{}]: host must not be empty", i); + } + // Protocol-specific validation + match &conn.endpoint { + Endpoint::Sftp(sftp) => { + if sftp.user.is_empty() { + anyhow::bail!("connections[{}]: SFTP user must not be empty", i); + } + } + Endpoint::Smb(smb) => { + if smb.user.is_empty() { + anyhow::bail!("connections[{}]: SMB user must not be empty", i); + } + if smb.share.is_empty() { + anyhow::bail!("connections[{}]: SMB share must not be empty", i); + } + if smb.share.contains(['/', '\\', ':']) { + anyhow::bail!( + "connections[{}]: SMB share '{}' must not contain /, \\, or :", + i, + smb.share + ); + } + if smb.pass.as_ref().map_or(true, |p| p.is_empty()) { + anyhow::bail!("connections[{}]: SMB password is required", i); + } + } + } } // At least one share required @@ -735,8 +858,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -761,12 +885,14 @@ mount_point = "/mnt/photos" assert_eq!(config.connections.len(), 1); assert_eq!(config.connections[0].name, "nas"); - assert_eq!(config.connections[0].nas_host, "10.0.0.1"); - assert_eq!(config.connections[0].nas_user, "admin"); - assert_eq!(config.connections[0].sftp_port, 22); - assert_eq!(config.connections[0].sftp_connections, 8); - assert!(config.connections[0].nas_pass.is_none()); - assert!(config.connections[0].nas_key_file.is_none()); + assert_eq!(config.connections[0].host, "10.0.0.1"); + assert_eq!(config.connections[0].protocol_name(), "sftp"); + assert_eq!(config.connections[0].user(), "admin"); + assert_eq!(config.connections[0].port(), 22); + let sftp = config.connections[0].sftp().unwrap(); + assert_eq!(sftp.connections, 8); + assert!(sftp.pass.is_none()); + assert!(sftp.key_file.is_none()); assert_eq!(config.cache.dir, PathBuf::from("/tmp/cache")); assert_eq!(config.cache.max_size, "200G"); @@ -810,12 +936,13 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "home" -nas_host = "192.168.1.100" -nas_user = "photographer" -nas_pass = "secret123" -nas_key_file = "/root/.ssh/id_rsa" -sftp_port = 2222 -sftp_connections = 16 +host = "192.168.1.100" +protocol = "sftp" +user = "photographer" +pass = "secret123" +key_file = "/root/.ssh/id_rsa" +port = 2222 +connections = 16 [cache] dir = "/mnt/ssd/cache" @@ -871,15 +998,14 @@ newer_than = "7d" let config: Config = toml::from_str(toml_str).unwrap(); assert_eq!(config.connections[0].name, "home"); - assert_eq!(config.connections[0].nas_host, "192.168.1.100"); - assert_eq!(config.connections[0].nas_user, "photographer"); - assert_eq!(config.connections[0].nas_pass.as_deref(), Some("secret123")); - assert_eq!( - config.connections[0].nas_key_file.as_deref(), - Some("/root/.ssh/id_rsa") - ); - assert_eq!(config.connections[0].sftp_port, 2222); - assert_eq!(config.connections[0].sftp_connections, 16); + assert_eq!(config.connections[0].host, "192.168.1.100"); + assert_eq!(config.connections[0].protocol_name(), "sftp"); + assert_eq!(config.connections[0].user(), "photographer"); + assert_eq!(config.connections[0].pass(), Some("secret123")); + let sftp = config.connections[0].sftp().unwrap(); + assert_eq!(sftp.key_file.as_deref(), Some("/root/.ssh/id_rsa")); + assert_eq!(sftp.port, 2222); + assert_eq!(sftp.connections, 16); assert_eq!(config.cache.max_size, "500G"); assert_eq!(config.cache.max_age, "1440h"); @@ -918,16 +1044,18 @@ newer_than = "7d" let toml_str = r#" [[connections]] name = "home" -nas_host = "10.0.0.1" -nas_user = "admin" -nas_key_file = "/root/.ssh/id_rsa" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" +key_file = "/root/.ssh/id_rsa" [[connections]] name = "office" -nas_host = "192.168.1.100" -nas_user = "photographer" -nas_pass = "secret" -sftp_port = 2222 +host = "192.168.1.100" +protocol = "sftp" +user = "photographer" +pass = "secret" +port = 2222 [cache] dir = "/tmp/cache" @@ -956,7 +1084,7 @@ mount_point = "/mnt/projects" assert_eq!(config.connections.len(), 2); assert_eq!(config.connections[0].name, "home"); assert_eq!(config.connections[1].name, "office"); - assert_eq!(config.connections[1].sftp_port, 2222); + assert_eq!(config.connections[1].port(), 2222); assert_eq!(config.shares[0].connection, "home"); assert_eq!(config.shares[1].connection, "office"); @@ -967,7 +1095,7 @@ mount_point = "/mnt/projects" let share = &config.shares[0]; let conn = config.connection_for_share(share).unwrap(); - assert_eq!(conn.nas_host, "10.0.0.1"); + assert_eq!(conn.host, "10.0.0.1"); } #[test] @@ -975,7 +1103,8 @@ mount_point = "/mnt/projects" let toml_str = r#" [[connections]] name = "nas" -nas_user = "admin" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1012,9 +1141,10 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" -sftp_connections = 999 +host = "10.0.0.1" +protocol = "sftp" +user = "admin" +connections = 999 [cache] dir = "/tmp/cache" @@ -1033,7 +1163,7 @@ remote_path = "/photos" mount_point = "/mnt/photos" "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.connections[0].sftp_connections, 999); + assert_eq!(config.connections[0].sftp().unwrap().connections, 999); assert_eq!(config.cache.max_size, "999T"); } @@ -1042,8 +1172,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [read] [bandwidth] @@ -1066,7 +1197,7 @@ mount_point = "/mnt/photos" let config: Config = toml::from_str(minimal_toml()).unwrap(); let serialized = toml::to_string(&config).unwrap(); let config2: Config = toml::from_str(&serialized).unwrap(); - assert_eq!(config.connections[0].nas_host, config2.connections[0].nas_host); + assert_eq!(config.connections[0].host, config2.connections[0].host); assert_eq!(config.cache.max_size, config2.cache.max_size); assert_eq!(config.writeback.transfers, config2.writeback.transfers); } @@ -1079,7 +1210,7 @@ mount_point = "/mnt/photos" let config2: Config = toml::from_str(&commented).unwrap(); config2.validate().unwrap(); assert_eq!(config.connections[0].name, config2.connections[0].name); - assert_eq!(config.connections[0].nas_host, config2.connections[0].nas_host); + assert_eq!(config.connections[0].host, config2.connections[0].host); assert_eq!(config.cache.dir, config2.cache.dir); assert_eq!(config.cache.max_size, config2.cache.max_size); assert_eq!(config.read.chunk_size, config2.read.chunk_size); @@ -1097,12 +1228,13 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "home" -nas_host = "192.168.1.100" -nas_user = "photographer" -nas_pass = "secret123" -nas_key_file = "/root/.ssh/id_rsa" -sftp_port = 2222 -sftp_connections = 16 +host = "192.168.1.100" +protocol = "sftp" +user = "photographer" +pass = "secret123" +key_file = "/root/.ssh/id_rsa" +port = 2222 +connections = 16 [cache] dir = "/mnt/ssd/cache" @@ -1167,9 +1299,9 @@ newer_than = "7d" config2.validate().unwrap(); // All fields should survive the round-trip - assert_eq!(config.connections[0].nas_pass, config2.connections[0].nas_pass); - assert_eq!(config.connections[0].nas_key_file, config2.connections[0].nas_key_file); - assert_eq!(config.connections[0].sftp_port, config2.connections[0].sftp_port); + assert_eq!(config.connections[0].pass(), config2.connections[0].pass()); + assert_eq!(config.connections[0].sftp().unwrap().key_file, config2.connections[0].sftp().unwrap().key_file); + assert_eq!(config.connections[0].port(), config2.connections[0].port()); assert_eq!(config.smb_auth.enabled, config2.smb_auth.enabled); assert_eq!(config.smb_auth.username, config2.smb_auth.username); assert_eq!(config.smb_auth.smb_pass, config2.smb_auth.smb_pass); @@ -1319,9 +1451,10 @@ path = "Images/2024" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" -nas_pass = "secret" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" +pass = "secret" [cache] dir = "/tmp/cache" @@ -1397,8 +1530,9 @@ read_only = true let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1441,8 +1575,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1473,8 +1608,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1504,8 +1640,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1555,8 +1692,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1589,13 +1727,15 @@ mount_point = "/mnt/other" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [[connections]] name = "nas" -nas_host = "10.0.0.2" -nas_user = "admin2" +host = "10.0.0.2" +protocol = "sftp" +user = "admin2" [cache] dir = "/tmp/cache" @@ -1622,8 +1762,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "my nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1650,8 +1791,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1678,8 +1820,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1706,8 +1849,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1734,8 +1878,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1762,8 +1907,9 @@ mount_point = "mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1796,8 +1942,9 @@ mount_point = "/mnt/data" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1831,8 +1978,9 @@ path = "2024" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1862,8 +2010,9 @@ mount_point = "/mnt/photos" let toml_str = r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -1889,6 +2038,48 @@ mount_point = "/mnt/photos" assert!(err.contains("username"), "got: {err}"); } + #[test] + fn test_validate_smb_share_illegal_chars() { + // Parse a valid SMB config, then mutate the share field to avoid TOML escaping issues. + let toml_str = r#" +[[connections]] +name = "nas" +host = "10.0.0.1" +protocol = "smb" +user = "admin" +pass = "secret" +share = "photos" + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +connection = "nas" +remote_path = "/" +mount_point = "/mnt/photos" +"#; + for bad_share in &["photos/raw", "photos\\raw", "photos:raw"] { + let mut config: Config = toml::from_str(toml_str).unwrap(); + if let Endpoint::Smb(ref mut smb) = config.connections[0].endpoint { + smb.share = bad_share.to_string(); + } + let err = config.validate().unwrap_err().to_string(); + assert!( + err.contains("must not contain"), + "share='{}' should fail validation, got: {}", + bad_share, + err + ); + } + } + #[test] fn test_is_valid_remote_name() { assert!(is_valid_remote_name("home")); diff --git a/src/config_diff.rs b/src/config_diff.rs index f114a84..89f72bc 100644 --- a/src/config_diff.rs +++ b/src/config_diff.rs @@ -286,8 +286,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -396,7 +397,7 @@ mount_point = "/mnt/photos" fn test_connection_modified_affects_shares() { let old = minimal_config(); let mut new = old.clone(); - new.connections[0].nas_host = "192.168.1.1".to_string(); + new.connections[0].host = "192.168.1.1".to_string(); let d = diff(&old, &new); assert_eq!(d.connections_modified, vec!["nas"]); // Share "photos" references "nas", so it should be in shares_modified @@ -411,12 +412,14 @@ mount_point = "/mnt/photos" let mut new = old.clone(); new.connections.push(crate::config::ConnectionConfig { name: "office".to_string(), - nas_host: "10.0.0.2".to_string(), - nas_user: "admin".to_string(), - nas_pass: None, - nas_key_file: None, - sftp_port: 22, - sftp_connections: 8, + host: "10.0.0.2".to_string(), + endpoint: crate::config::Endpoint::Sftp(crate::config::SftpEndpoint { + user: "admin".to_string(), + pass: None, + key_file: None, + port: 22, + connections: 8, + }), }); let d = diff(&old, &new); assert_eq!(d.connections_added, vec!["office"]); @@ -429,13 +432,15 @@ mount_point = "/mnt/photos" r#" [[connections]] name = "home" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [[connections]] name = "office" -nas_host = "10.0.0.2" -nas_user = "admin" +host = "10.0.0.2" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -477,13 +482,15 @@ mount_point = "/mnt/projects" r#" [[connections]] name = "home" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [[connections]] name = "office" -nas_host = "10.0.0.2" -nas_user = "admin" +host = "10.0.0.2" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" diff --git a/src/rclone/config.rs b/src/rclone/config.rs index 5f9394a..ced29a6 100644 --- a/src/rclone/config.rs +++ b/src/rclone/config.rs @@ -10,33 +10,54 @@ use crate::config::Config; /// Default path for generated rclone config. pub const RCLONE_CONF_PATH: &str = "/etc/warpgate/rclone.conf"; -/// Generate rclone.conf content with one SFTP remote section per connection. +/// Generate rclone.conf content with one remote section per connection. /// /// Each connection produces an INI-style `[name]` section (where `name` is -/// `ConnectionConfig.name`) containing all SFTP parameters. +/// `ConnectionConfig.name`) containing all protocol-specific parameters. pub fn generate(config: &Config) -> Result { + use crate::config::Endpoint; let mut conf = String::new(); for conn in &config.connections { writeln!(conf, "[{}]", conn.name)?; - writeln!(conf, "type = sftp")?; - writeln!(conf, "host = {}", conn.nas_host)?; - writeln!(conf, "user = {}", conn.nas_user)?; - writeln!(conf, "port = {}", conn.sftp_port)?; - if let Some(pass) = &conn.nas_pass { - let obscured = obscure_password(pass)?; - writeln!(conf, "pass = {obscured}")?; - } - if let Some(key_file) = &conn.nas_key_file { - writeln!(conf, "key_file = {key_file}")?; + match &conn.endpoint { + Endpoint::Sftp(sftp) => { + writeln!(conf, "type = sftp")?; + writeln!(conf, "host = {}", conn.host)?; + writeln!(conf, "user = {}", sftp.user)?; + writeln!(conf, "port = {}", sftp.port)?; + + if let Some(pass) = &sftp.pass { + let obscured = obscure_password(pass)?; + writeln!(conf, "pass = {obscured}")?; + } + if let Some(key_file) = &sftp.key_file { + writeln!(conf, "key_file = {key_file}")?; + } + + writeln!(conf, "connections = {}", sftp.connections)?; + + // Disable hash checking — many NAS SFTP servers (e.g. Synology) don't support + // running shell commands like md5sum, causing upload verification to fail. + writeln!(conf, "disable_hashcheck = true")?; + } + Endpoint::Smb(smb) => { + writeln!(conf, "type = smb")?; + writeln!(conf, "host = {}", conn.host)?; + writeln!(conf, "user = {}", smb.user)?; + writeln!(conf, "port = {}", smb.port)?; + + if let Some(pass) = &smb.pass { + let obscured = obscure_password(pass)?; + writeln!(conf, "pass = {obscured}")?; + } + if let Some(domain) = &smb.domain { + writeln!(conf, "domain = {domain}")?; + } + } } - writeln!(conf, "connections = {}", conn.sftp_connections)?; - - // Disable hash checking — many NAS SFTP servers (e.g. Synology) don't support - // running shell commands like md5sum, causing upload verification to fail. - writeln!(conf, "disable_hashcheck = true")?; writeln!(conf)?; // blank line between sections } @@ -83,8 +104,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -125,7 +147,9 @@ mount_point = "/mnt/photos" #[test] fn test_generate_rclone_config_with_key_file() { let mut config = test_config(); - config.connections[0].nas_key_file = Some("/root/.ssh/id_rsa".into()); + if let crate::config::Endpoint::Sftp(ref mut sftp) = config.connections[0].endpoint { + sftp.key_file = Some("/root/.ssh/id_rsa".into()); + } let content = generate(&config).unwrap(); assert!(content.contains("key_file = /root/.ssh/id_rsa")); @@ -134,8 +158,10 @@ mount_point = "/mnt/photos" #[test] fn test_generate_rclone_config_custom_port_and_connections() { let mut config = test_config(); - config.connections[0].sftp_port = 2222; - config.connections[0].sftp_connections = 16; + if let crate::config::Endpoint::Sftp(ref mut sftp) = config.connections[0].endpoint { + sftp.port = 2222; + sftp.connections = 16; + } let content = generate(&config).unwrap(); assert!(content.contains("port = 2222")); @@ -160,14 +186,16 @@ mount_point = "/mnt/photos" r#" [[connections]] name = "home" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [[connections]] name = "office" -nas_host = "192.168.1.100" -nas_user = "photographer" -sftp_port = 2222 +host = "192.168.1.100" +protocol = "sftp" +user = "photographer" +port = 2222 [cache] dir = "/tmp/cache" @@ -205,4 +233,50 @@ mount_point = "/mnt/projects" assert!(content.contains("user = photographer")); assert!(content.contains("port = 2222")); } + + #[test] + fn test_generate_smb_connection() { + // Note: no password to avoid requiring `rclone obscure` in test env. + // generate() doesn't call validate(), so missing password is fine here. + let config: Config = toml::from_str( + r#" +[[connections]] +name = "office" +host = "192.168.1.100" +protocol = "smb" +user = "photographer" +share = "photos" + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +connection = "office" +remote_path = "/subfolder" +mount_point = "/mnt/photos" +"#, + ) + .unwrap(); + + let content = generate(&config).unwrap(); + + assert!(content.contains("[office]")); + assert!(content.contains("type = smb")); + assert!(content.contains("host = 192.168.1.100")); + assert!(content.contains("user = photographer")); + assert!(content.contains("port = 445")); + // Should NOT contain SFTP-specific fields + assert!(!content.contains("connections =")); + assert!(!content.contains("disable_hashcheck")); + assert!(!content.contains("key_file")); + // Should NOT contain password line (no pass set) + assert!(!content.contains("pass =")); + } } diff --git a/src/rclone/mod.rs b/src/rclone/mod.rs index f0005d8..91700a1 100644 --- a/src/rclone/mod.rs +++ b/src/rclone/mod.rs @@ -1,4 +1,5 @@ pub mod config; pub mod mount; +pub mod path; pub mod probe; pub mod rc; diff --git a/src/rclone/mount.rs b/src/rclone/mount.rs index 0b8aa50..dd4fb9e 100644 --- a/src/rclone/mount.rs +++ b/src/rclone/mount.rs @@ -16,7 +16,13 @@ pub fn build_mount_args(config: &Config, share: &ShareConfig, rc_port: u16) -> V // Subcommand and source:dest args.push("mount".into()); - args.push(format!("{}:{}", share.connection, share.remote_path)); + let source = if let Some(conn) = config.connection_for_share(share) { + super::path::rclone_remote_path(conn, share) + } else { + // Fallback if connection not found (shouldn't happen with validated config) + format!("{}:{}", share.connection, share.remote_path) + }; + args.push(source); args.push(share.mount_point.display().to_string()); // Point to our generated rclone.conf @@ -169,8 +175,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" diff --git a/src/rclone/path.rs b/src/rclone/path.rs new file mode 100644 index 0000000..dbcac47 --- /dev/null +++ b/src/rclone/path.rs @@ -0,0 +1,309 @@ +//! Path resolution for rclone remotes across protocols. +//! +//! SFTP and SMB have different rclone path semantics: +//! +//! | Operation | SFTP | SMB | +//! |-----------|--------------------|-----------------------------| +//! | Mount | `conn:/vol/photos` | `conn:sharename/subfolder` | +//! | Test | `conn:/` | `conn:sharename/` | +//! | Browse | `conn:/path` | `conn:sharename/path` | + +use std::path::PathBuf; + +use crate::config::{ConnectionConfig, Endpoint, ShareConfig}; + +/// Low-level: build an SMB rclone path with share prefix. +/// +/// Joins `remote_name:share/path`, stripping any leading `/` from `path`. +/// Used by both `ConnectionConfig`-based and `ConnParams`-based callers +/// so the SMB path rule lives in exactly one place. +pub(crate) fn smb_remote(remote_name: &str, share: &str, path: &str) -> String { + let relative = path.trim_start_matches('/'); + if relative.is_empty() { + format!("{}:{}", remote_name, share) + } else { + format!("{}:{}/{}", remote_name, share, relative) + } +} + +/// Build the rclone remote path for mounting a share. +/// +/// - SFTP: `connection:remote_path` (e.g. `nas:/volume1/photos`) +/// - SMB: `connection:share/relative_path` (e.g. `office:photos/subfolder`) +/// +/// For SMB, the share's `remote_path` is treated as relative within `SmbEndpoint.share`. +/// A leading `/` is stripped. +pub fn rclone_remote_path(conn: &ConnectionConfig, share: &ShareConfig) -> String { + match &conn.endpoint { + Endpoint::Sftp(_) => { + format!("{}:{}", share.connection, share.remote_path) + } + Endpoint::Smb(smb) => smb_remote(&share.connection, &smb.share, &share.remote_path), + } +} + +/// Build an rclone path for a sub-path within a share (warmup, speed-test, etc.). +/// +/// Appends `subpath` to the base `rclone_remote_path`. +/// - SFTP: `nas:/volume1/photos/2024` +/// - SMB: `office:photos/subfolder/2024` +pub fn rclone_remote_subpath( + conn: &ConnectionConfig, + share: &ShareConfig, + subpath: &str, +) -> String { + let base = rclone_remote_path(conn, share); + let subpath = subpath.trim_matches('/'); + if subpath.is_empty() { + base + } else { + // Trim trailing '/' from base to avoid double-slash (e.g. "nas:/" + "foo" → "nas:/foo") + let base = base.trim_end_matches('/'); + format!("{}/{}", base, subpath) + } +} + +/// Return the relative directory under the rclone VFS cache for a share. +/// +/// rclone stores cached files at `{cache_dir}/vfs/{connection}/{path}`. +/// - SFTP `nas:/volume1/photos` → `nas/volume1/photos` +/// - SMB `office:photos/subfolder` → `office/photos/subfolder` +pub fn vfs_cache_prefix(conn: &ConnectionConfig, share: &ShareConfig) -> PathBuf { + let remote = rclone_remote_path(conn, share); + // remote is "name:path" — split on first ':' + let (name, path) = remote.split_once(':').unwrap_or((&remote, "")); + let path = path.trim_start_matches('/'); + PathBuf::from(name).join(path) +} + +/// Build the rclone remote path for testing connectivity (list root). +/// +/// - SFTP: `connection:/` +/// - SMB: `connection:share/` +pub fn rclone_test_path(conn: &ConnectionConfig) -> String { + match &conn.endpoint { + Endpoint::Sftp(_) => { + format!("{}:/", conn.name) + } + Endpoint::Smb(smb) => { + format!("{}:{}/", conn.name, smb.share) + } + } +} + +/// Build the rclone remote path for browsing directories. +/// +/// - SFTP: `connection:path` +/// - SMB: `connection:share/path` +pub fn rclone_browse_path(conn: &ConnectionConfig, path: &str) -> String { + match &conn.endpoint { + Endpoint::Sftp(_) => { + format!("{}:{}", conn.name, path) + } + Endpoint::Smb(smb) => smb_remote(&conn.name, &smb.share, path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Endpoint, SftpEndpoint, SmbEndpoint}; + + fn sftp_conn() -> ConnectionConfig { + ConnectionConfig { + name: "nas".into(), + host: "10.0.0.1".into(), + endpoint: Endpoint::Sftp(SftpEndpoint { + user: "admin".into(), + pass: None, + key_file: None, + port: 22, + connections: 8, + }), + } + } + + fn smb_conn() -> ConnectionConfig { + ConnectionConfig { + name: "office".into(), + host: "192.168.1.100".into(), + endpoint: Endpoint::Smb(SmbEndpoint { + user: "photographer".into(), + pass: Some("secret".into()), + domain: None, + port: 445, + share: "photos".into(), + }), + } + } + + fn share(conn_name: &str, remote_path: &str) -> ShareConfig { + ShareConfig { + name: "test".into(), + connection: conn_name.into(), + remote_path: remote_path.into(), + mount_point: "/mnt/test".into(), + read_only: false, + dir_refresh_interval: None, + } + } + + // --- rclone_remote_path --- + + #[test] + fn test_sftp_remote_path() { + let conn = sftp_conn(); + let s = share("nas", "/volume1/photos"); + assert_eq!(rclone_remote_path(&conn, &s), "nas:/volume1/photos"); + } + + #[test] + fn test_sftp_remote_path_root() { + let conn = sftp_conn(); + let s = share("nas", "/"); + assert_eq!(rclone_remote_path(&conn, &s), "nas:/"); + } + + #[test] + fn test_smb_remote_path() { + let conn = smb_conn(); + let s = share("office", "/subfolder"); + assert_eq!(rclone_remote_path(&conn, &s), "office:photos/subfolder"); + } + + #[test] + fn test_smb_remote_path_root() { + let conn = smb_conn(); + let s = share("office", "/"); + assert_eq!(rclone_remote_path(&conn, &s), "office:photos"); + } + + #[test] + fn test_smb_remote_path_nested() { + let conn = smb_conn(); + let s = share("office", "/2024/wedding"); + assert_eq!( + rclone_remote_path(&conn, &s), + "office:photos/2024/wedding" + ); + } + + // --- rclone_test_path --- + + #[test] + fn test_sftp_test_path() { + assert_eq!(rclone_test_path(&sftp_conn()), "nas:/"); + } + + #[test] + fn test_smb_test_path() { + assert_eq!(rclone_test_path(&smb_conn()), "office:photos/"); + } + + // --- rclone_browse_path --- + + #[test] + fn test_sftp_browse_path() { + assert_eq!(rclone_browse_path(&sftp_conn(), "/volume1"), "nas:/volume1"); + } + + #[test] + fn test_sftp_browse_path_root() { + assert_eq!(rclone_browse_path(&sftp_conn(), "/"), "nas:/"); + } + + #[test] + fn test_smb_browse_path() { + assert_eq!( + rclone_browse_path(&smb_conn(), "/subfolder"), + "office:photos/subfolder" + ); + } + + #[test] + fn test_smb_browse_path_root() { + assert_eq!(rclone_browse_path(&smb_conn(), "/"), "office:photos"); + } + + // --- rclone_remote_subpath --- + + #[test] + fn test_sftp_subpath() { + let conn = sftp_conn(); + let s = share("nas", "/volume1/photos"); + assert_eq!( + rclone_remote_subpath(&conn, &s, "2024"), + "nas:/volume1/photos/2024" + ); + } + + #[test] + fn test_sftp_subpath_empty() { + let conn = sftp_conn(); + let s = share("nas", "/volume1/photos"); + assert_eq!( + rclone_remote_subpath(&conn, &s, ""), + "nas:/volume1/photos" + ); + } + + #[test] + fn test_sftp_subpath_root_share_no_double_slash() { + // When remote_path is "/", base is "nas:/" — must not produce "nas://foo" + let conn = sftp_conn(); + let s = share("nas", "/"); + assert_eq!(rclone_remote_subpath(&conn, &s, "foo"), "nas:/foo"); + } + + #[test] + fn test_smb_subpath() { + let conn = smb_conn(); + let s = share("office", "/subfolder"); + assert_eq!( + rclone_remote_subpath(&conn, &s, "2024"), + "office:photos/subfolder/2024" + ); + } + + #[test] + fn test_smb_subpath_root_share() { + let conn = smb_conn(); + let s = share("office", "/"); + assert_eq!( + rclone_remote_subpath(&conn, &s, "2024"), + "office:photos/2024" + ); + } + + // --- vfs_cache_prefix --- + + #[test] + fn test_sftp_vfs_cache_prefix() { + let conn = sftp_conn(); + let s = share("nas", "/volume1/photos"); + assert_eq!( + vfs_cache_prefix(&conn, &s), + std::path::PathBuf::from("nas/volume1/photos") + ); + } + + #[test] + fn test_smb_vfs_cache_prefix() { + let conn = smb_conn(); + let s = share("office", "/subfolder"); + assert_eq!( + vfs_cache_prefix(&conn, &s), + std::path::PathBuf::from("office/photos/subfolder") + ); + } + + #[test] + fn test_smb_vfs_cache_prefix_root() { + let conn = smb_conn(); + let s = share("office", "/"); + assert_eq!( + vfs_cache_prefix(&conn, &s), + std::path::PathBuf::from("office/photos") + ); + } +} diff --git a/src/rclone/probe.rs b/src/rclone/probe.rs index 8c9a555..a557375 100644 --- a/src/rclone/probe.rs +++ b/src/rclone/probe.rs @@ -21,8 +21,12 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(10); /// Runs: `rclone lsf : --max-depth 1 --config ` /// /// Returns `Ok(())` if the directory exists, `Err` with a descriptive message if not. -pub fn probe_remote_path(_config: &Config, share: &ShareConfig) -> Result<()> { - let remote = format!("{}:{}", share.connection, share.remote_path); +pub fn probe_remote_path(config: &Config, share: &ShareConfig) -> Result<()> { + let remote = if let Some(conn) = config.connection_for_share(share) { + super::path::rclone_remote_path(conn, share) + } else { + format!("{}:{}", share.connection, share.remote_path) + }; let mut child = Command::new("rclone") .args([ @@ -86,12 +90,22 @@ pub fn probe_remote_path(_config: &Config, share: &ShareConfig) -> Result<()> { } /// Parameters for an ad-hoc connection (used by test and browse). -pub struct ConnParams { - pub nas_host: String, - pub nas_user: String, - pub nas_pass: Option, - pub nas_key_file: Option, - pub sftp_port: u16, +pub enum ConnParams { + Sftp { + host: String, + user: String, + pass: Option, + key_file: Option, + port: u16, + }, + Smb { + host: String, + user: String, + pass: Option, + domain: Option, + port: u16, + share: String, + }, } /// A temporary file that is deleted when dropped. @@ -111,27 +125,50 @@ impl Drop for TempConf { } } -/// Write a temporary rclone config with a single SFTP remote named `remote_name`. +/// Write a temporary rclone config with a single remote named `remote_name`. fn write_temp_rclone_conf(params: &ConnParams, remote_name: &str) -> Result { let mut conf = String::new(); writeln!(conf, "[{remote_name}]").unwrap(); - writeln!(conf, "type = sftp").unwrap(); - writeln!(conf, "host = {}", params.nas_host).unwrap(); - writeln!(conf, "user = {}", params.nas_user).unwrap(); - writeln!(conf, "port = {}", params.sftp_port).unwrap(); - if let Some(pass) = ¶ms.nas_pass { - if !pass.is_empty() { - let obscured = obscure_password(pass)?; - writeln!(conf, "pass = {obscured}").unwrap(); + match params { + ConnParams::Sftp { host, user, pass, key_file, port } => { + writeln!(conf, "type = sftp").unwrap(); + writeln!(conf, "host = {host}").unwrap(); + writeln!(conf, "user = {user}").unwrap(); + writeln!(conf, "port = {port}").unwrap(); + + if let Some(pass) = pass { + if !pass.is_empty() { + let obscured = obscure_password(pass)?; + writeln!(conf, "pass = {obscured}").unwrap(); + } + } + if let Some(key_file) = key_file { + if !key_file.is_empty() { + writeln!(conf, "key_file = {key_file}").unwrap(); + } + } + writeln!(conf, "disable_hashcheck = true").unwrap(); + } + ConnParams::Smb { host, user, pass, domain, port, .. } => { + writeln!(conf, "type = smb").unwrap(); + writeln!(conf, "host = {host}").unwrap(); + writeln!(conf, "user = {user}").unwrap(); + writeln!(conf, "port = {port}").unwrap(); + + if let Some(pass) = pass { + if !pass.is_empty() { + let obscured = obscure_password(pass)?; + writeln!(conf, "pass = {obscured}").unwrap(); + } + } + if let Some(domain) = domain { + if !domain.is_empty() { + writeln!(conf, "domain = {domain}").unwrap(); + } + } } } - if let Some(key_file) = ¶ms.nas_key_file { - if !key_file.is_empty() { - writeln!(conf, "key_file = {key_file}").unwrap(); - } - } - writeln!(conf, "disable_hashcheck = true").unwrap(); let uid = uuid_short(); let path = format!("/tmp/wg-test-{uid}.conf"); @@ -192,12 +229,17 @@ fn run_rclone_lsf(args: &[&str], timeout: Duration) -> Result { /// Test whether a connection is reachable by listing the root directory. /// -/// Returns `Ok(())` if rclone can connect and list `/`, `Err` with an error message if not. +/// Returns `Ok(())` if rclone can connect and list, `Err` with an error message if not. +/// - SFTP: lists `remote:/` +/// - SMB: lists `remote:share/` pub fn test_connection(params: &ConnParams) -> Result<()> { let remote_name = format!("wg-test-{}", uuid_short()); let tmp = write_temp_rclone_conf(params, &remote_name)?; let conf_path = tmp.path().to_string(); - let remote = format!("{remote_name}:/"); + let remote = match params { + ConnParams::Sftp { .. } => format!("{remote_name}:/"), + ConnParams::Smb { share, .. } => format!("{remote_name}:{share}/"), + }; run_rclone_lsf( &["lsf", &remote, "--max-depth", "1", "--config", &conf_path], @@ -207,11 +249,16 @@ pub fn test_connection(params: &ConnParams) -> Result<()> { } /// List subdirectories at `path` on the remote, returning their names (without trailing `/`). +/// +/// For SMB, the path is relative to the share name. pub fn browse_dirs(params: &ConnParams, path: &str) -> Result> { let remote_name = format!("wg-test-{}", uuid_short()); let tmp = write_temp_rclone_conf(params, &remote_name)?; let conf_path = tmp.path().to_string(); - let remote = format!("{remote_name}:{path}"); + let remote = match params { + ConnParams::Sftp { .. } => format!("{remote_name}:{path}"), + ConnParams::Smb { share, .. } => super::path::smb_remote(&remote_name, share, path), + }; let stdout = run_rclone_lsf( &[ diff --git a/src/services/nfs.rs b/src/services/nfs.rs index 0deb3d0..3726704 100644 --- a/src/services/nfs.rs +++ b/src/services/nfs.rs @@ -60,8 +60,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -87,8 +88,9 @@ mount_point = "/mnt/photos" r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" diff --git a/src/services/samba.rs b/src/services/samba.rs index 1817073..b1ba001 100644 --- a/src/services/samba.rs +++ b/src/services/samba.rs @@ -177,8 +177,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -204,8 +205,9 @@ mount_point = "/mnt/photos" r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -244,8 +246,9 @@ read_only = true r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" diff --git a/src/services/systemd.rs b/src/services/systemd.rs index 1429e12..d189ca7 100644 --- a/src/services/systemd.rs +++ b/src/services/systemd.rs @@ -71,8 +71,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" diff --git a/src/services/webdav.rs b/src/services/webdav.rs index 6242504..7953372 100644 --- a/src/services/webdav.rs +++ b/src/services/webdav.rs @@ -35,8 +35,9 @@ mod tests { r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" @@ -85,8 +86,9 @@ mount_point = "/mnt/photos" r#" [[connections]] name = "nas" -nas_host = "10.0.0.1" -nas_user = "admin" +host = "10.0.0.1" +protocol = "sftp" +user = "admin" [cache] dir = "/tmp/cache" diff --git a/src/supervisor.rs b/src/supervisor.rs index e62e520..227389b 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -1298,6 +1298,18 @@ fn handle_reload( *cfg = new_config.clone(); } + // Collect affected share names from the diff for health recalculation. + // For Tier D (global), all shares are affected; for Tier C (per-share), + // only modified/added shares need fresh health. + let affected_shares: std::collections::HashSet<&str> = if diff.global_changed { + new_config.shares.iter().map(|s| s.name.as_str()).collect() + } else { + diff.shares_modified.iter() + .chain(diff.shares_added.iter()) + .map(|s| s.as_str()) + .collect() + }; + // Update shared status with new share list { let mut status = shared_status.write().unwrap(); @@ -1306,11 +1318,15 @@ fn handle_reload( .iter() .enumerate() .map(|(i, s)| { - // Preserve existing stats if share still exists let existing = status.shares.iter().find(|ss| ss.name == s.name); + let is_affected = affected_shares.contains(s.name.as_str()); crate::daemon::ShareStatus { name: s.name.clone(), - mounted: existing.map(|e| e.mounted).unwrap_or(false), + mounted: if is_affected { + mounts.iter().any(|mc| mc.name == s.name) + } else { + existing.map(|e| e.mounted).unwrap_or(false) + }, rc_port: new_config.rc_port(i), cache_bytes: existing.map(|e| e.cache_bytes).unwrap_or(0), dirty_count: existing.map(|e| e.dirty_count).unwrap_or(0), @@ -1318,16 +1334,19 @@ fn handle_reload( speed: existing.map(|e| e.speed).unwrap_or(0.0), transfers: existing.map(|e| e.transfers).unwrap_or(0), errors: existing.map(|e| e.errors).unwrap_or(0), - health: existing - .map(|e| e.health.clone()) - .unwrap_or_else(|| { - // New share: if mount succeeded, it's healthy - if mounts.iter().any(|mc| mc.name == s.name) { - ShareHealth::Healthy - } else { - ShareHealth::Pending - } - }), + health: if is_affected { + // Recalculate health based on mount success + if mounts.iter().any(|mc| mc.name == s.name) { + ShareHealth::Healthy + } else { + ShareHealth::Failed("Mount failed after reload".into()) + } + } else { + // Unaffected share: preserve existing health + existing + .map(|e| e.health.clone()) + .unwrap_or(ShareHealth::Pending) + }, } }) .collect(); diff --git a/src/web/api.rs b/src/web/api.rs index 93e7f23..a76c90a 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -424,21 +424,14 @@ async fn get_logs( }) } -/// POST /api/test-connection — verify SFTP credentials can connect. +/// POST /api/test-connection — verify credentials can connect. +/// +/// Accepts a connection object. The `name` field is optional (ignored by the probe). #[derive(serde::Deserialize)] struct TestConnRequest { - nas_host: String, - nas_user: String, - #[serde(default)] - nas_pass: Option, - #[serde(default)] - nas_key_file: Option, - #[serde(default = "default_sftp_port")] - sftp_port: u16, -} - -fn default_sftp_port() -> u16 { - 22 + host: String, + #[serde(flatten)] + endpoint: crate::config::Endpoint, } #[derive(Serialize)] @@ -447,18 +440,34 @@ struct TestConnResponse { message: String, } +/// Convert a test/browse request into ConnParams for probe functions. +fn req_to_params(host: &str, endpoint: &crate::config::Endpoint) -> crate::rclone::probe::ConnParams { + use crate::config::Endpoint; + match endpoint { + Endpoint::Sftp(sftp) => crate::rclone::probe::ConnParams::Sftp { + host: host.to_string(), + user: sftp.user.clone(), + pass: sftp.pass.clone(), + key_file: sftp.key_file.clone(), + port: sftp.port, + }, + Endpoint::Smb(smb) => crate::rclone::probe::ConnParams::Smb { + host: host.to_string(), + user: smb.user.clone(), + pass: smb.pass.clone(), + domain: smb.domain.clone(), + port: smb.port, + share: smb.share.clone(), + }, + } +} + const TEST_CONNECTION_TIMEOUT: Duration = Duration::from_secs(12); async fn post_test_connection( Json(body): Json, ) -> Json { - let params = crate::rclone::probe::ConnParams { - nas_host: body.nas_host, - nas_user: body.nas_user, - nas_pass: body.nas_pass, - nas_key_file: body.nas_key_file, - sftp_port: body.sftp_port, - }; + let params = req_to_params(&body.host, &body.endpoint); match timeout( TEST_CONNECTION_TIMEOUT, @@ -489,16 +498,13 @@ async fn post_test_connection( } /// POST /api/browse — list subdirectories at a remote path. +/// +/// Accepts a connection object (without `name`). The `path` field is optional. #[derive(serde::Deserialize)] struct BrowseRequest { - nas_host: String, - nas_user: String, - #[serde(default)] - nas_pass: Option, - #[serde(default)] - nas_key_file: Option, - #[serde(default = "default_sftp_port")] - sftp_port: u16, + host: String, + #[serde(flatten)] + endpoint: crate::config::Endpoint, #[serde(default = "default_browse_path")] path: String, } @@ -519,13 +525,7 @@ struct BrowseResponse { async fn post_browse( Json(body): Json, ) -> Json { - let params = crate::rclone::probe::ConnParams { - nas_host: body.nas_host, - nas_user: body.nas_user, - nas_pass: body.nas_pass, - nas_key_file: body.nas_key_file, - sftp_port: body.sftp_port, - }; + let params = req_to_params(&body.host, &body.endpoint); let path = body.path; match tokio::task::spawn_blocking(move || crate::rclone::probe::browse_dirs(¶ms, &path)).await { diff --git a/static/style.css b/static/style.css index 5fef06e..02417d1 100644 --- a/static/style.css +++ b/static/style.css @@ -843,6 +843,59 @@ textarea:focus { margin-left: auto; } +/* ─── Apply modal ─────────────────────────────────────── */ + +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.6); + display: flex; align-items: center; justify-content: center; + z-index: 1000; +} +.modal-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 32px; + min-width: 380px; + max-width: 460px; +} +.modal-title { + font-size: 1.1em; + margin-bottom: 20px; +} +.modal-steps { display: flex; flex-direction: column; gap: 14px; } +.modal-step { + display: flex; align-items: center; gap: 12px; + font-size: 0.92em; + color: var(--text-muted); + transition: color 0.2s; +} +.modal-step.step-done { color: var(--green); } +.modal-step.step-active { color: var(--text); } +.modal-step.step-error { color: var(--red); } + +.step-icon { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.step-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); } +.step-spinner { + width: 16px; height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.modal-error { + margin-top: 14px; + padding: 10px 14px; + background: rgba(248,113,113,0.1); + border: 1px solid var(--red); + border-radius: 6px; + color: var(--red); + font-size: 0.85em; +} +.modal-footer { margin-top: 20px; text-align: right; } + /* ─── Responsive ───────────────────────────────────────── */ @media (max-width: 768px) { diff --git a/templates/config.toml.default b/templates/config.toml.default index c050c08..ab9c4b2 100644 --- a/templates/config.toml.default +++ b/templates/config.toml.default @@ -2,33 +2,48 @@ # See: https://github.com/user/warpgate for documentation # --- NAS Connections --- -# Each connection defines an SFTP endpoint to a remote NAS. +# Each connection defines an endpoint to a remote NAS. +# Supported protocols: sftp, smb # The "name" is used as the rclone remote identifier and must be unique. [[connections]] # Unique name for this connection (alphanumeric, hyphens, underscores) name = "nas" # Remote NAS Tailscale IP or hostname -nas_host = "100.x.x.x" -# SFTP username -nas_user = "admin" -# SFTP password (prefer key_file for security) -# nas_pass = "your-password" -# Path to SSH private key (recommended) -# nas_key_file = "/root/.ssh/id_ed25519" -# SFTP port -sftp_port = 22 +host = "100.x.x.x" +# Protocol: "sftp" or "smb" +protocol = "sftp" +# Username +user = "admin" +# Password (prefer key_file for SFTP) +# pass = "your-password" +# Path to SSH private key (SFTP only, recommended) +# key_file = "/root/.ssh/id_ed25519" +# Port (SFTP default: 22, SMB default: 445) +port = 22 # SFTP connection pool size (if multi_thread_streams=4, recommend >= 16) -sftp_connections = 8 +connections = 8 -# --- Additional NAS (uncomment to add) --- +# --- Additional NAS via SFTP (uncomment to add) --- # [[connections]] # name = "office" -# nas_host = "192.168.1.100" -# nas_user = "photographer" -# nas_pass = "secret" -# sftp_port = 22 -# sftp_connections = 8 +# host = "192.168.1.100" +# protocol = "sftp" +# user = "photographer" +# pass = "secret" +# port = 22 +# connections = 8 + +# --- SMB connection example (uncomment to add) --- +# [[connections]] +# name = "smb-nas" +# host = "192.168.1.200" +# protocol = "smb" +# user = "admin" +# pass = "password" # Required for SMB +# share = "photos" # Windows share name +# # domain = "WORKGROUP" # Optional domain +# # port = 445 # Default: 445 [cache] # Cache storage directory (should be on SSD, prefer btrfs/ZFS filesystem) @@ -50,7 +65,7 @@ read_ahead = "512M" # In-memory buffer size buffer_size = "256M" # Number of parallel SFTP streams for single-file downloads (improves cold-read speed) -# If using multi_thread_streams=4, set sftp_connections >= 16 for multi-file concurrency +# If using multi_thread_streams=4, set connections >= 16 for multi-file concurrency multi_thread_streams = 4 # Minimum file size to trigger multi-thread download multi_thread_cutoff = "50M" @@ -98,11 +113,16 @@ webdav_port = 8080 # Each share maps a remote NAS path to a local mount point. # Each gets its own rclone mount process with independent FUSE mount. # The "connection" field references a [[connections]] entry by name. +# +# remote_path semantics differ by protocol: +# SFTP: absolute path on the NAS, e.g. "/volume1/photos" +# SMB: path relative to the share defined in the connection, e.g. "/" or "/subfolder" +# (the SMB share name itself is set in [[connections]]) [[shares]] name = "photos" connection = "nas" -remote_path = "/volume1/photos" +remote_path = "/volume1/photos" # SFTP absolute path; for SMB use "/" or "/subfolder" mount_point = "/mnt/photos" # [[shares]] diff --git a/templates/web/partials/share_rows.html b/templates/web/partials/share_rows.html index d5d192c..202a15a 100644 --- a/templates/web/partials/share_rows.html +++ b/templates/web/partials/share_rows.html @@ -1,7 +1,7 @@
{% for share in shares %} -
diff --git a/templates/web/tabs/config.html b/templates/web/tabs/config.html index 82843c0..a371468 100644 --- a/templates/web/tabs/config.html +++ b/templates/web/tabs/config.html @@ -8,8 +8,16 @@ function configEditorFn() { function _prepareForEdit(config) { for (const conn of config.connections) { - if (conn.nas_pass == null) conn.nas_pass = ''; - if (conn.nas_key_file == null) conn.nas_key_file = ''; + // Ensure protocol field exists (default sftp) + if (!conn.protocol) conn.protocol = 'sftp'; + // Ensure all optional fields exist for Alpine.js binding + if (conn.pass == null) conn.pass = ''; + if (conn.key_file == null) conn.key_file = ''; + if (conn.domain == null) conn.domain = ''; + if (conn.share == null) conn.share = ''; + // Ensure numeric fields have defaults + if (conn.port == null) conn.port = conn.protocol === 'smb' ? 445 : 22; + if (conn.connections == null) conn.connections = 8; } if (config.smb_auth.username == null) config.smb_auth.username = ''; if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = ''; @@ -30,6 +38,7 @@ function configEditorFn() { submitting: false, message: _initData.message || null, isError: _initData.is_error || false, + applyModal: { open: false, steps: [], error: null, done: false }, connTest: {}, browseState: {}, sections: { @@ -62,8 +71,18 @@ function configEditorFn() { prepareForSubmit(config) { const c = JSON.parse(JSON.stringify(config)); for (const conn of c.connections) { - if (!conn.nas_pass) conn.nas_pass = null; - if (!conn.nas_key_file) conn.nas_key_file = null; + if (!conn.pass) conn.pass = null; + if (conn.protocol === 'sftp') { + // SFTP: keep key_file, connections; remove SMB-only fields + if (!conn.key_file) conn.key_file = null; + delete conn.domain; + delete conn.share; + } else { + // SMB: keep domain, share; remove SFTP-only fields + if (!conn.domain) conn.domain = null; + delete conn.key_file; + delete conn.connections; + } } if (!c.smb_auth.username) c.smb_auth.username = null; if (!c.smb_auth.smb_pass) c.smb_auth.smb_pass = null; @@ -78,9 +97,10 @@ function configEditorFn() { addConnection() { this.config.connections.push({ - name: '', nas_host: '', nas_user: '', - nas_pass: '', nas_key_file: '', - sftp_port: 22, sftp_connections: 8 + name: '', host: '', protocol: 'sftp', + user: '', pass: '', key_file: '', + port: 22, connections: 8, + domain: '', share: '' }); }, @@ -107,16 +127,11 @@ function configEditorFn() { if (this.connTest[i] && this.connTest[i].loading) return; this.connTest = { ...this.connTest, [i]: { loading: true, ok: null, message: '' } }; try { + const payload = this._connPayload(conn); const resp = await fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - nas_host: conn.nas_host, - nas_user: conn.nas_user, - nas_pass: conn.nas_pass || null, - nas_key_file: conn.nas_key_file || null, - sftp_port: conn.sftp_port || 22, - }) + body: JSON.stringify(payload) }); const result = await resp.json(); this.connTest = { ...this.connTest, [i]: { loading: false, ok: result.ok, message: result.message } }; @@ -125,16 +140,23 @@ function configEditorFn() { } }, - _connParamsFor(connName) { - const conn = this.config.connections.find(c => c.name === connName); - if (!conn) return null; - return { - nas_host: conn.nas_host, - nas_user: conn.nas_user, - nas_pass: conn.nas_pass || null, - nas_key_file: conn.nas_key_file || null, - sftp_port: conn.sftp_port || 22, + /** Build a connection payload for test/browse API (name not required). */ + _connPayload(conn) { + const base = { + host: conn.host, + protocol: conn.protocol || 'sftp', + user: conn.user, + pass: conn.pass || null, + port: conn.port, }; + if (base.protocol === 'sftp') { + base.key_file = conn.key_file || null; + base.connections = conn.connections || 8; + } else { + base.domain = conn.domain || null; + base.share = conn.share || ''; + } + return base; }, async browseDir(share, i) { @@ -146,17 +168,11 @@ function configEditorFn() { return; } try { + const payload = { ...this._connPayload(conn), path }; const resp = await fetch('/api/browse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - nas_host: conn.nas_host, - nas_user: conn.nas_user, - nas_pass: conn.nas_pass || null, - nas_key_file: conn.nas_key_file || null, - sftp_port: conn.sftp_port || 22, - path, - }) + body: JSON.stringify(payload) }); const result = await resp.json(); if (result.ok) { @@ -179,6 +195,18 @@ function configEditorFn() { async submitConfig() { this.submitting = true; this.message = null; + this.applyModal = { + open: true, + error: null, + done: false, + steps: [ + { label: 'Validating configuration', status: 'active' }, + { label: 'Writing config file', status: 'pending' }, + { label: 'Sending reload command', status: 'pending' }, + { label: 'Restarting services', status: 'pending' }, + ] + }; + try { const payload = this.prepareForSubmit(this.config); const resp = await fetch('/config/apply', { @@ -187,18 +215,64 @@ function configEditorFn() { body: JSON.stringify(payload) }); const result = await resp.json(); - this.message = result.message; - this.isError = !result.ok; - if (result.ok) { - this.originalConfig = JSON.parse(JSON.stringify(this.config)); + + if (!result.ok) { + this.applyModal.steps[0].status = 'error'; + this.applyModal.error = result.message; + this.submitting = false; + return; } + + // Steps 1-3 all completed (single API call) + this.applyModal.steps[0].status = 'done'; + this.applyModal.steps[1].status = 'done'; + this.applyModal.steps[2].status = 'done'; + this.applyModal.steps[3].status = 'active'; + this.message = result.message; + this.isError = false; + this.originalConfig = JSON.parse(JSON.stringify(this.config)); + + // Watch SSE for service readiness + this._waitForServicesReady(); } catch (e) { - this.message = 'Network error: ' + e.message; - this.isError = true; + this.applyModal.steps[0].status = 'error'; + this.applyModal.error = 'Network error: ' + e.message; } this.submitting = false; }, + _waitForServicesReady() { + const checkInterval = setInterval(() => { + const shareRows = document.querySelectorAll('[data-share-health]'); + if (shareRows.length === 0) { + clearInterval(checkInterval); + this._markServicesDone(); + return; + } + const allSettled = Array.from(shareRows).every(el => { + const h = el.dataset.shareHealth; + return h && h !== 'PENDING' && h !== 'PROBING'; + }); + if (allSettled) { + clearInterval(checkInterval); + this._markServicesDone(); + } + }, 500); + + // Safety timeout: 30s max wait + setTimeout(() => { + clearInterval(checkInterval); + if (this.applyModal.steps[3].status === 'active') { + this._markServicesDone(); + } + }, 30000); + }, + + _markServicesDone() { + this.applyModal.steps[3].status = 'done'; + this.applyModal.done = true; + }, + resetConfig() { this.config = JSON.parse(JSON.stringify(this.originalConfig)); this.message = null; @@ -291,28 +365,46 @@ if (window.Alpine) {
- - + + +
+
+ +
- +
- +
+ + +
+ +
- +
-
- - -
-
+
- + +
+ +
+ + +
+
+ +
@@ -758,4 +850,45 @@ if (window.Alpine) {
+ + +