From 85e682c815e69e6e681acd9c1a51ab666dc58f83 Mon Sep 17 00:00:00 2001 From: grabbit Date: Thu, 19 Feb 2026 23:20:50 +0800 Subject: [PATCH] Add rclone connection probe helpers and config UI styles --- src/rclone/config.rs | 2 +- src/rclone/probe.rs | 163 ++++++++++++++++++++++++++++++++++++++++++- static/style.css | 119 +++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 2 deletions(-) diff --git a/src/rclone/config.rs b/src/rclone/config.rs index cdc6216..5f9394a 100644 --- a/src/rclone/config.rs +++ b/src/rclone/config.rs @@ -44,7 +44,7 @@ pub fn generate(config: &Config) -> Result { } /// Obscure a password using `rclone obscure` (required for rclone.conf). -fn obscure_password(plain: &str) -> Result { +pub(crate) fn obscure_password(plain: &str) -> Result { let output = std::process::Command::new("rclone") .args(["obscure", plain]) .output() diff --git a/src/rclone/probe.rs b/src/rclone/probe.rs index ea44cd4..8c9a555 100644 --- a/src/rclone/probe.rs +++ b/src/rclone/probe.rs @@ -4,13 +4,14 @@ //! This prevents rclone from mounting a FUSE filesystem that silently fails //! when clients try to access it. +use std::fmt::Write as FmtWrite; use std::process::Command; use std::time::Duration; use anyhow::{Context, Result}; use crate::config::{Config, ShareConfig}; -use crate::rclone::config::RCLONE_CONF_PATH; +use crate::rclone::config::{obscure_password, RCLONE_CONF_PATH}; /// Probe timeout per share. const PROBE_TIMEOUT: Duration = Duration::from_secs(10); @@ -84,6 +85,166 @@ 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, +} + +/// A temporary file that is deleted when dropped. +struct TempConf { + path: String, +} + +impl TempConf { + fn path(&self) -> &str { + &self.path + } +} + +impl Drop for TempConf { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// Write a temporary rclone config with a single SFTP 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(); + } + } + 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"); + std::fs::write(&path, conf.as_bytes()) + .with_context(|| format!("Failed to write temp rclone config: {path}"))?; + Ok(TempConf { path }) +} + +/// Run `rclone lsf` with a timeout, returning stdout on success or an error message. +fn run_rclone_lsf(args: &[&str], timeout: Duration) -> Result { + let mut child = Command::new("rclone") + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("Failed to spawn rclone")?; + + let deadline = std::time::Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + if status.success() { + let stdout = if let Some(mut out) = child.stdout.take() { + let mut buf = String::new(); + std::io::Read::read_to_string(&mut out, &mut buf).unwrap_or(0); + buf + } else { + String::new() + }; + return Ok(stdout); + } + let stderr = if let Some(mut err) = child.stderr.take() { + let mut buf = String::new(); + std::io::Read::read_to_string(&mut err, &mut buf).unwrap_or(0); + buf + } else { + String::new() + }; + let msg = stderr.trim(); + if msg.is_empty() { + anyhow::bail!("rclone exited with code {}", status.code().unwrap_or(-1)); + } else { + anyhow::bail!("{}", extract_rclone_error(msg)); + } + } + Ok(None) => { + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("timed out after {}s", timeout.as_secs()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) => anyhow::bail!("failed to poll rclone: {e}"), + } + } +} + +/// 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. +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}:/"); + + run_rclone_lsf( + &["lsf", &remote, "--max-depth", "1", "--config", &conf_path], + PROBE_TIMEOUT, + )?; + Ok(()) +} + +/// List subdirectories at `path` on the remote, returning their names (without trailing `/`). +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 stdout = run_rclone_lsf( + &[ + "lsf", + &remote, + "--max-depth", + "1", + "--dirs-only", + "--config", + &conf_path, + ], + PROBE_TIMEOUT, + )?; + + let dirs = stdout + .lines() + .map(|l| l.trim_end_matches('/').to_string()) + .filter(|l| !l.is_empty()) + .collect(); + Ok(dirs) +} + +/// Generate a short random hex string for unique naming (no external rand dependency). +fn uuid_short() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let pid = std::process::id(); + format!("{:08x}{:08x}", pid, nanos) +} + /// Extract the most useful part of rclone's error output. /// /// rclone stderr often contains timestamps and log levels; we strip those diff --git a/static/style.css b/static/style.css index 768e8ad..5fef06e 100644 --- a/static/style.css +++ b/static/style.css @@ -456,6 +456,125 @@ textarea:focus { color: var(--accent); } +/* ─── Connection test button ──────────────────────────────── */ + +.item-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.test-btn { + background: none; + border: 1px solid rgba(108,138,255,0.4); + color: var(--accent); + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 0.8em; +} + +.test-btn:hover:not(:disabled) { background: rgba(108,138,255,0.1); } +.test-btn:disabled { opacity: 0.5; cursor: default; } + +.test-ok { + font-size: 0.8em; + color: var(--green); + white-space: nowrap; +} + +.test-fail { + font-size: 0.8em; + color: var(--red); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ─── Remote Path browse combo ────────────────────────────── */ + +.browse-combo { + display: flex; + gap: 6px; + align-items: center; + width: 100%; +} + +.browse-combo input { + flex: 1; +} + +.browse-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; + white-space: nowrap; + flex-shrink: 0; +} + +.browse-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.browse-btn:disabled { opacity: 0.5; cursor: default; } + +.dir-dropdown { + margin-top: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + max-height: 200px; + overflow-y: auto; +} + +.dir-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + gap: 8px; +} + +.dir-item:last-child { border-bottom: none; } +.dir-item:hover { background: rgba(108,138,255,0.06); } + +.dir-name { + font-family: var(--mono); + font-size: 0.85em; + cursor: pointer; + color: var(--text); + flex: 1; +} + +.dir-name:hover { color: var(--accent); } + +.dir-enter { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 0.9em; + padding: 2px 6px; + border-radius: 3px; + flex-shrink: 0; +} + +.dir-enter:hover { color: var(--accent); background: rgba(108,138,255,0.1); } + +.browse-error { + margin-top: 4px; + font-size: 0.82em; + color: var(--red); +} + /* Toggle switch */ .toggle { position: relative;