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 <noreply@anthropic.com>
This commit is contained in:
parent
85e682c815
commit
d2b9f46b1a
@ -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");
|
||||
}
|
||||
|
||||
134
src/cli/setup.rs
134
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<PathBuf>) -> 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 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 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"));
|
||||
let (nas_pass, nas_key_file) = match auth_method.as_str() {
|
||||
match auth_method.as_str() {
|
||||
"2" => {
|
||||
let key = prompt("SSH private key path", Some("/root/.ssh/id_rsa"));
|
||||
(None, Some(key))
|
||||
(None, Some(key), None, None)
|
||||
}
|
||||
_ => {
|
||||
let pass = prompt_password("SFTP password");
|
||||
if pass.is_empty() {
|
||||
anyhow::bail!("Password is required");
|
||||
}
|
||||
(Some(pass), None)
|
||||
(Some(pass), None, None, 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<PathBuf>) -> 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<PathBuf>) -> 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,45 +236,44 @@ pub fn run(output: Option<PathBuf>) -> 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::<SocketAddr>() {
|
||||
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
|
||||
),
|
||||
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(),
|
||||
},
|
||||
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
|
||||
);
|
||||
}
|
||||
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!(
|
||||
"Cannot resolve hostname '{}' — check NAS host and ensure DNS is working.\nDetails: {}",
|
||||
nas_host, e
|
||||
"Connection test failed for {}:{} — {}\n\
|
||||
Check host, credentials, and ensure rclone is installed.",
|
||||
nas_host, conn_port, e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Write config ---
|
||||
let config_path = output.unwrap_or_else(|| PathBuf::from("/etc/warpgate/config.toml"));
|
||||
|
||||
@ -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...");
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
403
src/config.rs
403
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<String>,
|
||||
/// Path to SSH private key.
|
||||
pub pass: Option<String>,
|
||||
#[serde(default)]
|
||||
pub nas_key_file: Option<String>,
|
||||
/// SFTP port.
|
||||
pub key_file: Option<String>,
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub domain: Option<String>,
|
||||
#[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"));
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<String> {
|
||||
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 {
|
||||
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) = &conn.nas_key_file {
|
||||
if let Some(key_file) = &sftp.key_file {
|
||||
writeln!(conf, "key_file = {key_file}")?;
|
||||
}
|
||||
|
||||
writeln!(conf, "connections = {}", conn.sftp_connections)?;
|
||||
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)?; // 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 ="));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod mount;
|
||||
pub mod path;
|
||||
pub mod probe;
|
||||
pub mod rc;
|
||||
|
||||
@ -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"
|
||||
|
||||
309
src/rclone/path.rs
Normal file
309
src/rclone/path.rs
Normal file
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -21,8 +21,12 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Runs: `rclone lsf <connection>:<remote_path> --max-depth 1 --config <rclone.conf>`
|
||||
///
|
||||
/// 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<String>,
|
||||
pub nas_key_file: Option<String>,
|
||||
pub sftp_port: u16,
|
||||
pub enum ConnParams {
|
||||
Sftp {
|
||||
host: String,
|
||||
user: String,
|
||||
pass: Option<String>,
|
||||
key_file: Option<String>,
|
||||
port: u16,
|
||||
},
|
||||
Smb {
|
||||
host: String,
|
||||
user: String,
|
||||
pass: Option<String>,
|
||||
domain: Option<String>,
|
||||
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<TempConf> {
|
||||
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 {
|
||||
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) = ¶ms.nas_key_file {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<String> {
|
||||
|
||||
/// 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<Vec<String>> {
|
||||
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(
|
||||
&[
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
health: if is_affected {
|
||||
// Recalculate health based on mount success
|
||||
if mounts.iter().any(|mc| mc.name == s.name) {
|
||||
ShareHealth::Healthy
|
||||
} else {
|
||||
ShareHealth::Pending
|
||||
ShareHealth::Failed("Mount failed after reload".into())
|
||||
}
|
||||
}),
|
||||
} else {
|
||||
// Unaffected share: preserve existing health
|
||||
existing
|
||||
.map(|e| e.health.clone())
|
||||
.unwrap_or(ShareHealth::Pending)
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -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<String>,
|
||||
#[serde(default)]
|
||||
nas_key_file: Option<String>,
|
||||
#[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<TestConnRequest>,
|
||||
) -> Json<TestConnResponse> {
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
nas_key_file: Option<String>,
|
||||
#[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<BrowseRequest>,
|
||||
) -> Json<BrowseResponse> {
|
||||
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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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]]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="share-rows" hx-swap-oob="innerHTML:#share-rows">
|
||||
<div class="cards">
|
||||
{% for share in shares %}
|
||||
<div class="card" style="cursor:pointer"
|
||||
<div class="card" style="cursor:pointer" data-share-health="{{ share.health }}"
|
||||
hx-get="/tabs/shares?expand={{ share.name }}" hx-target="#tab-content" hx-swap="innerHTML"
|
||||
@click="activeTab = 'shares'">
|
||||
<div class="card-header">
|
||||
|
||||
@ -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) {
|
||||
<input type="text" x-model="conn.name" required placeholder="e.g. home-nas">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>NAS Host *</label>
|
||||
<input type="text" x-model="conn.nas_host" required placeholder="e.g. 100.64.0.1">
|
||||
<label>Protocol *</label>
|
||||
<select x-model="conn.protocol" @change="conn.port = conn.protocol === 'smb' ? 445 : 22">
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="smb">SMB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Host *</label>
|
||||
<input type="text" x-model="conn.host" required placeholder="e.g. 100.64.0.1">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Username *</label>
|
||||
<input type="text" x-model="conn.nas_user" required placeholder="e.g. admin">
|
||||
<input type="text" x-model="conn.user" required placeholder="e.g. admin">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Password</label>
|
||||
<input type="password" x-model="conn.nas_pass" placeholder="(optional if using key)">
|
||||
<input type="password" x-model="conn.pass"
|
||||
:placeholder="conn.protocol === 'smb' ? 'Required for SMB' : '(optional if using key)'">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Port</label>
|
||||
<input type="number" x-model.number="conn.port" min="1" max="65535">
|
||||
</div>
|
||||
<!-- SFTP-only fields -->
|
||||
<div class="field-row" x-show="conn.protocol === 'sftp'" x-transition>
|
||||
<label>SSH Key File</label>
|
||||
<input type="text" x-model="conn.nas_key_file" class="mono" placeholder="/root/.ssh/id_rsa">
|
||||
<input type="text" x-model="conn.key_file" class="mono" placeholder="/root/.ssh/id_rsa">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>SFTP Port</label>
|
||||
<input type="number" x-model.number="conn.sftp_port" min="1" max="65535">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-row" x-show="conn.protocol === 'sftp'" x-transition>
|
||||
<label>SFTP Connections</label>
|
||||
<input type="number" x-model.number="conn.sftp_connections" min="1" max="128">
|
||||
<input type="number" x-model.number="conn.connections" min="1" max="128">
|
||||
</div>
|
||||
<!-- SMB-only fields -->
|
||||
<div class="field-row" x-show="conn.protocol === 'smb'" x-transition>
|
||||
<label>Share Name *</label>
|
||||
<input type="text" x-model="conn.share" required placeholder="e.g. photos">
|
||||
</div>
|
||||
<div class="field-row" x-show="conn.protocol === 'smb'" x-transition>
|
||||
<label>Domain</label>
|
||||
<input type="text" x-model="conn.domain" placeholder="e.g. WORKGROUP (optional)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -758,4 +850,45 @@ if (window.Alpine) {
|
||||
<button type="button" @click="resetConfig()" class="btn btn-secondary">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Apply Config Progress Modal -->
|
||||
<div class="modal-overlay" x-show="applyModal.open" x-transition.opacity x-cloak
|
||||
@keydown.escape.window="if (applyModal.done || applyModal.error) applyModal.open = false">
|
||||
<div class="modal-card" @click.stop>
|
||||
<h3 class="modal-title">Applying Configuration</h3>
|
||||
<div class="modal-steps">
|
||||
<template x-for="(step, i) in applyModal.steps" :key="i">
|
||||
<div class="modal-step" :class="'step-' + step.status">
|
||||
<span class="step-icon">
|
||||
<template x-if="step.status === 'done'">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 8.5L6.5 12L13 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="step.status === 'active'">
|
||||
<span class="step-spinner"></span>
|
||||
</template>
|
||||
<template x-if="step.status === 'error'">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="step.status === 'pending'">
|
||||
<span class="step-dot"></span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="step-label" x-text="step.label"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="applyModal.error" class="modal-error" x-text="applyModal.error"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary"
|
||||
x-show="applyModal.done || applyModal.error"
|
||||
@click="applyModal.open = false">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user