Add rclone connection probe helpers and config UI styles
This commit is contained in:
parent
3a858431f1
commit
85e682c815
@ -44,7 +44,7 @@ pub fn generate(config: &Config) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Obscure a password using `rclone obscure` (required for rclone.conf).
|
/// Obscure a password using `rclone obscure` (required for rclone.conf).
|
||||||
fn obscure_password(plain: &str) -> Result<String> {
|
pub(crate) fn obscure_password(plain: &str) -> Result<String> {
|
||||||
let output = std::process::Command::new("rclone")
|
let output = std::process::Command::new("rclone")
|
||||||
.args(["obscure", plain])
|
.args(["obscure", plain])
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
@ -4,13 +4,14 @@
|
|||||||
//! This prevents rclone from mounting a FUSE filesystem that silently fails
|
//! This prevents rclone from mounting a FUSE filesystem that silently fails
|
||||||
//! when clients try to access it.
|
//! when clients try to access it.
|
||||||
|
|
||||||
|
use std::fmt::Write as FmtWrite;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use crate::config::{Config, ShareConfig};
|
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.
|
/// Probe timeout per share.
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
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<String>,
|
||||||
|
pub nas_key_file: Option<String>,
|
||||||
|
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<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 {
|
||||||
|
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<String> {
|
||||||
|
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<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 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.
|
/// Extract the most useful part of rclone's error output.
|
||||||
///
|
///
|
||||||
/// rclone stderr often contains timestamps and log levels; we strip those
|
/// rclone stderr often contains timestamps and log levels; we strip those
|
||||||
|
|||||||
119
static/style.css
119
static/style.css
@ -456,6 +456,125 @@ textarea:focus {
|
|||||||
color: var(--accent);
|
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 switch */
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user