Feat: Kompletter Rewrite von SmartMount 2.0.0
Ersetzt die alte Single-Paar-Implementierung durch eine modulare Architektur, die beliebig viele lokale/Cloud-Laufwerkspaare verwaltet und automatisch zwischen ihnen umschaltet: - Dynamisch dispatchte Mount-Backends für WebDAV (davfs2), SMB/CIFS und NFS, inkl. NFS-"soft"-Resilienz-Defaults gegen unbegrenztes Hängen bei nicht erreichbaren Servern. - Verschlüsselte Zugangsdaten-Ablage in einer eingebetteten Turso-DB (AES-256-GCM), Master-Key aus OS-Keyring mit Datei-Fallback. - Lokal-Adressierung per IP oder MAC (Auflösung über das externe Tool `mac2ip`). - Symlink-basiertes Umschalten zwischen zwei eindeutigen Backing- Verzeichnissen pro Paar, statt zweier fstab-Zeilen auf denselben Mountpoint (siehe `mount/target.rs` für die Begründung). - Einmaliges root-Setup (`setup fstab`) für unprivilegierte User- Kontext-Mounts inkl. automatischer Verzeichnis-Ownership unter `/run/media`. - systemd-Units (System/User) mit automatischem Cron-Fallback für Systeme ohne systemd, inkl. sauberem Uninstall aller Artefakte. - `doctor`-Diagnose, Shell-Completions, JSON-Ausgabe für Skript-Automatisierung. - Alle Terminal-Ausgaben (Logs, Fehler, Prompts, --help) auf Englisch, Code-Kommentare und Doku weiterhin auf Deutsch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZ3VzCWgbQRMyFEEKPvnZz
This commit is contained in:
Generated
+3714
File diff suppressed because it is too large
Load Diff
+75
-14
@@ -1,25 +1,86 @@
|
||||
[package]
|
||||
name = "SmartMount"
|
||||
version = "0.2.0"
|
||||
name = "smart-mount"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
authors = ['DragonSlayer_14']
|
||||
readme = "README.md"
|
||||
license-file = "LICENSE"
|
||||
repository = "https://gitea.creative-dragonslayer.de/creative-dragonslayer/SmartMount"
|
||||
description = "SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs."
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://gitea.creative-dragonslayer.de/Linuxapps/SmartMount"
|
||||
description = "Bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und schaltet automatisch zwischen LAN und Cloud um"
|
||||
|
||||
[dependencies]
|
||||
time = { version="0.3.41", features = ["formatting", "macros", "local-offset"] }
|
||||
serde = { version="1.0.219", features = ["derive"] }
|
||||
confy = "1.0.0"
|
||||
libc = "1.0.0-alpha.1"
|
||||
config-ctdra = { version = "1.0.6", registry = "gitea" }
|
||||
logger-ctdra = { version = "1.0.5", registry = "gitea" }
|
||||
program-ctdra = { version = "1.0.1", registry = "gitea" }
|
||||
sudo-ctdra = { version = "1.0.1", registry = "gitea" }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "fs", "time", "sync"] }
|
||||
# WORKAROUND für RUSTSEC-2026-0253 (Use-after-free in lru < 0.18.2), siehe mac2ip Cargo.toml:
|
||||
# das Default-Feature "fts" zieht tantivy -> lru "^0.16.3" (verwundbar) ein, obwohl
|
||||
# smart-mount keine Volltextsuche nutzt. "fts" bleibt deaktiviert, bis eine stabile
|
||||
# turso-Version tantivy>=eine lru>=0.18.2 zulassende Version pinnt.
|
||||
turso = { version = "0.7", default-features = false, features = ["mimalloc"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
aes-gcm = "0.11.1"
|
||||
keyring = "4.2.0"
|
||||
dialoguer = "0.12.0"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms
|
||||
# rand_core-Re-Export-Kette (siehe crypto/mod.rs, crypto/key.rs).
|
||||
getrandom = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
toml = "1.1.6"
|
||||
|
||||
[profile.release]
|
||||
debug = "none"
|
||||
|
||||
[package.metadata.deb]
|
||||
section = "utils"
|
||||
priority = "optional"
|
||||
# --- Paketierungs-Metadaten für Linux-Distributionen ---
|
||||
|
||||
provides = ["smartmount"]
|
||||
depends = ["nmap", "$auto"]
|
||||
[package.metadata.deb]
|
||||
name = "smart-mount"
|
||||
maintainer = "DragonSlayer_14"
|
||||
copyright = "2026 DragonSlayer_14"
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
# mac2ip und nmap sind harte Laufzeitabhängigkeiten (MAC->IP-Auflösung); davfs2/cifs-utils/
|
||||
# nfs-common werden bewusst NICHT hart verlangt, da smart-mount pro konfiguriertem Laufwerk
|
||||
# nur das jeweils benötigte Mount-Backend zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
|
||||
depends = "$auto, mac2ip, nmap"
|
||||
recommends = "davfs2, cifs-utils, nfs-common"
|
||||
extended-description = """\
|
||||
smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud-Laufwerk
|
||||
ein: ist das lokale Laufwerk erreichbar, wird es gemountet, sonst automatisch das
|
||||
Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf
|
||||
zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank
|
||||
gespeichert; smart-mount kann als root/System-Dienst und als Nutzer-Dienst laufen.\
|
||||
"""
|
||||
assets = [
|
||||
["target/release/smart-mount", "usr/bin/smart-mount", "755"],
|
||||
["README.md", "usr/share/doc/smart-mount/README.md", "644"],
|
||||
["LICENSE", "usr/share/doc/smart-mount/copyright", "644"],
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
assets = [
|
||||
{ source = "target/release/smart-mount", dest = "/usr/bin/smart-mount", mode = "755" },
|
||||
{ source = "README.md", dest = "/usr/share/doc/smart-mount/README.md", mode = "644", doc = true },
|
||||
{ source = "LICENSE", dest = "/usr/share/licenses/smart-mount/LICENSE", mode = "644", license = true },
|
||||
]
|
||||
requires = { "mac2ip" = "*", "nmap" = "*" }
|
||||
suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" }
|
||||
|
||||
[package.metadata.arch]
|
||||
pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben
|
||||
arch = "x86_64"
|
||||
depends = ["gcc-libs", "glibc", "mac2ip", "nmap"]
|
||||
optdepends = [
|
||||
"davfs2: WebDAV-Laufwerke einbinden",
|
||||
"cifs-utils: SMB/CIFS-Laufwerke einbinden",
|
||||
"nfs-utils: NFS-Laufwerke einbinden",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `smart-mount doctor` - prüft die im Laufe der Entwicklung angesammelten Voraussetzungen
|
||||
//! (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) gebündelt an einer Stelle.
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::doctor::{self, CheckStatus};
|
||||
|
||||
pub async fn run(json: bool) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let results = doctor::run_checks(&cfg);
|
||||
|
||||
if json {
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResult<'a> {
|
||||
label: &'a str,
|
||||
status: &'static str,
|
||||
detail: &'a str,
|
||||
}
|
||||
let json_results: Vec<JsonResult> = results
|
||||
.iter()
|
||||
.map(|r| JsonResult {
|
||||
label: &r.label,
|
||||
status: match r.status {
|
||||
CheckStatus::Ok => "ok",
|
||||
CheckStatus::Warn => "warn",
|
||||
CheckStatus::Fail => "fail",
|
||||
},
|
||||
detail: &r.detail,
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&json_results)?);
|
||||
} else {
|
||||
for r in &results {
|
||||
let symbol = match r.status {
|
||||
CheckStatus::Ok => "[ok] ",
|
||||
CheckStatus::Warn => "[warn]",
|
||||
CheckStatus::Fail => "[FAIL]",
|
||||
};
|
||||
println!("{symbol} {}: {}", r.label, r.detail);
|
||||
}
|
||||
}
|
||||
|
||||
let failures = results
|
||||
.iter()
|
||||
.filter(|r| r.status == CheckStatus::Fail)
|
||||
.count();
|
||||
if failures > 0 {
|
||||
anyhow::bail!("{failures} problem(s) found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
//! `smart-mount drive add|edit|remove|list`.
|
||||
//!
|
||||
//! `add`/`edit` unterstützen zwei Modi: interaktiv (Standard, `dialoguer`-Prompts, mit
|
||||
//! bereits per Flag/`edit` vorhandenen Werten als Vorbelegung) und nicht-interaktiv
|
||||
//! (`--non-interactive`, für Skripte/Automatisierung - fehlende Pflichtfelder sind dann ein
|
||||
//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde).
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use dialoguer::{Confirm, Input, Password, Select};
|
||||
|
||||
use smart_mount::config::{
|
||||
self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountContext, MountKind,
|
||||
};
|
||||
use smart_mount::db::credentials::{Credential, CredentialStore, Side};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DriveAction {
|
||||
/// Create a new drive pair (interactive, unless all fields are given via flags).
|
||||
Add(DriveArgs),
|
||||
/// Edit an existing drive pair - only the given fields change.
|
||||
Edit {
|
||||
id: String,
|
||||
#[command(flatten)]
|
||||
args: DriveArgs,
|
||||
},
|
||||
/// Remove an existing drive pair.
|
||||
Remove { id: String },
|
||||
/// List all configured drive pairs.
|
||||
List {
|
||||
/// Output as JSON instead of a table - for scripts.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Default)]
|
||||
pub struct DriveArgs {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long, value_enum)]
|
||||
context: Option<MountContext>,
|
||||
#[arg(long)]
|
||||
owner_user: Option<String>,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
local_kind: Option<MountKind>,
|
||||
#[arg(long, conflicts_with = "local_mac")]
|
||||
local_ip: Option<Ipv4Addr>,
|
||||
#[arg(long, conflicts_with = "local_ip")]
|
||||
local_mac: Option<String>,
|
||||
#[arg(long)]
|
||||
local_share: Option<String>,
|
||||
#[arg(long)]
|
||||
local_username: Option<String>,
|
||||
/// Insecure (visible in process listing/shell history) - prefer
|
||||
/// `--local-password-stdin` for scripts.
|
||||
#[arg(long, conflicts_with = "local_password_stdin")]
|
||||
local_password: Option<String>,
|
||||
/// Reads the local password as one line from stdin instead of passing it as an argument.
|
||||
#[arg(long)]
|
||||
local_password_stdin: bool,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
cloud_kind: Option<MountKind>,
|
||||
#[arg(long)]
|
||||
cloud_host: Option<String>,
|
||||
#[arg(long)]
|
||||
cloud_share: Option<String>,
|
||||
#[arg(long)]
|
||||
cloud_username: Option<String>,
|
||||
/// Insecure (visible in process listing/shell history) - prefer
|
||||
/// `--cloud-password-stdin` for scripts.
|
||||
#[arg(long, conflicts_with = "cloud_password_stdin")]
|
||||
cloud_password: Option<String>,
|
||||
/// Reads the cloud password as one line from stdin instead of passing it as an argument.
|
||||
#[arg(long)]
|
||||
cloud_password_stdin: bool,
|
||||
|
||||
/// Does not prompt for anything interactively - missing required fields cause an error
|
||||
/// instead of a prompt (which would fail anyway without a terminal). For scripts/automation.
|
||||
#[arg(long)]
|
||||
non_interactive: bool,
|
||||
}
|
||||
|
||||
pub async fn run(action: DriveAction) -> anyhow::Result<()> {
|
||||
match action {
|
||||
DriveAction::Add(args) => add(args).await,
|
||||
DriveAction::Edit { id, args } => edit(&id, args).await,
|
||||
DriveAction::Remove { id } => remove(&id).await,
|
||||
DriveAction::List { json } => list(json).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(json: bool) -> anyhow::Result<()> {
|
||||
let cfg: AppConfig = config::pairs::load()?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&cfg.pairs)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cfg.pairs.is_empty() {
|
||||
println!("No drive pairs configured.");
|
||||
return Ok(());
|
||||
}
|
||||
for pair in &cfg.pairs {
|
||||
println!(
|
||||
"{} \"{}\" [{:?}] lokal={} cloud={} -> {}",
|
||||
pair.id,
|
||||
pair.name,
|
||||
pair.context,
|
||||
pair.local.kind.as_str(),
|
||||
pair.cloud.kind.as_str(),
|
||||
pair.mount_point.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(id: &str) -> anyhow::Result<()> {
|
||||
// Vor dem Entfernen aus der Config nachschlagen, damit wir hinterher noch wissen, welche
|
||||
// Mount-Typen/Adressen betroffen sind - nötig, um die passenden Klartext-Zugangsdaten
|
||||
// (davfs2 secrets, .cred-Datei) aufzuräumen, siehe smart_mount::mount::cleanup_credentials.
|
||||
let cfg = config::pairs::load()?;
|
||||
let pair = config::pairs::find_pair(&cfg, id)?;
|
||||
|
||||
config::pairs::remove_pair(id)?;
|
||||
let creds = CredentialStore::open().await?;
|
||||
creds.delete(id, None).await?;
|
||||
smart_mount::mount::cleanup_credentials(&pair, &cfg.settings);
|
||||
|
||||
println!("Drive pair '{id}' removed.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add(args: DriveArgs) -> anyhow::Result<()> {
|
||||
let ni = args.non_interactive;
|
||||
|
||||
let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required");
|
||||
let context = resolve_context(args.context, None, ni)?;
|
||||
let owner_user = resolve_owner_user(args.owner_user, context, None, ni)?;
|
||||
|
||||
let local_kind = resolve_kind(args.local_kind, None, "Local mount type", ni)?;
|
||||
let local_address = resolve_local_address(args.local_ip, args.local_mac, None, ni)?;
|
||||
let local_share = resolve_field(args.local_share, None, "Local share/export path", ni, true)?
|
||||
.expect("required");
|
||||
let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?;
|
||||
let (local_username, local_password) = resolve_credentials(
|
||||
local_kind,
|
||||
args.local_username,
|
||||
local_password_flag,
|
||||
None,
|
||||
ni,
|
||||
"Local credentials",
|
||||
)?;
|
||||
|
||||
let cloud_kind = resolve_kind(args.cloud_kind, None, "Cloud mount type", ni)?;
|
||||
let cloud_host = resolve_field(args.cloud_host, None, "Cloud server address/URL", ni, true)?
|
||||
.expect("required");
|
||||
let cloud_share = resolve_field(args.cloud_share, None, "Cloud share/export path", ni, true)?
|
||||
.expect("required");
|
||||
let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?;
|
||||
let (cloud_username, cloud_password) = resolve_credentials(
|
||||
cloud_kind,
|
||||
args.cloud_username,
|
||||
cloud_password_flag,
|
||||
None,
|
||||
ni,
|
||||
"Cloud credentials",
|
||||
)?;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let settings = config::pairs::load()
|
||||
.map(|c| c.settings)
|
||||
.unwrap_or_default();
|
||||
let mount_point = settings.mount_base_dir.join(&id);
|
||||
|
||||
let pair = DrivePair {
|
||||
id: id.clone(),
|
||||
name,
|
||||
enabled: true,
|
||||
context,
|
||||
owner_user,
|
||||
mount_point,
|
||||
local: LocalSide {
|
||||
kind: local_kind,
|
||||
address: local_address,
|
||||
share: local_share,
|
||||
username: local_username.clone(),
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: cloud_kind,
|
||||
host_or_url: cloud_host,
|
||||
share: cloud_share,
|
||||
username: cloud_username.clone(),
|
||||
extra_options: vec![],
|
||||
},
|
||||
};
|
||||
|
||||
config::pairs::add_pair(pair)?;
|
||||
|
||||
let creds = CredentialStore::open().await?;
|
||||
if let Some(pw) = local_password {
|
||||
creds
|
||||
.put(&id, Side::Local, local_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
if let Some(pw) = cloud_password {
|
||||
creds
|
||||
.put(&id, Side::Cloud, cloud_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
|
||||
println!("Drive pair '{id}' created.");
|
||||
if context == MountContext::User {
|
||||
println!("Note: for user-context pairs, 'sudo smart-mount setup fstab' must be run once.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let existing = config::pairs::find_pair(&cfg, id)?;
|
||||
let ni = args.non_interactive;
|
||||
|
||||
let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)?
|
||||
.expect("required");
|
||||
let context = resolve_context(args.context, Some(existing.context), ni)?;
|
||||
let owner_user =
|
||||
resolve_owner_user(args.owner_user, context, existing.owner_user.as_deref(), ni)?;
|
||||
|
||||
let local_kind = resolve_kind(
|
||||
args.local_kind,
|
||||
Some(existing.local.kind),
|
||||
"Local mount type",
|
||||
ni,
|
||||
)?;
|
||||
let local_address = resolve_local_address(
|
||||
args.local_ip,
|
||||
args.local_mac,
|
||||
Some(&existing.local.address),
|
||||
ni,
|
||||
)?;
|
||||
let local_share = resolve_field(
|
||||
args.local_share,
|
||||
Some(&existing.local.share),
|
||||
"Local share/export path",
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
.expect("required");
|
||||
|
||||
let creds = CredentialStore::open().await?;
|
||||
let current_local_cred = creds.get(id, Side::Local).await?;
|
||||
let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?;
|
||||
let (local_username, local_password) = resolve_credentials(
|
||||
local_kind,
|
||||
args.local_username,
|
||||
local_password_flag,
|
||||
current_local_cred.as_ref(),
|
||||
ni,
|
||||
"Local credentials",
|
||||
)?;
|
||||
|
||||
let cloud_kind = resolve_kind(
|
||||
args.cloud_kind,
|
||||
Some(existing.cloud.kind),
|
||||
"Cloud mount type",
|
||||
ni,
|
||||
)?;
|
||||
let cloud_host = resolve_field(
|
||||
args.cloud_host,
|
||||
Some(&existing.cloud.host_or_url),
|
||||
"Cloud server address/URL",
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
.expect("required");
|
||||
let cloud_share = resolve_field(
|
||||
args.cloud_share,
|
||||
Some(&existing.cloud.share),
|
||||
"Cloud share/export path",
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
.expect("required");
|
||||
let current_cloud_cred = creds.get(id, Side::Cloud).await?;
|
||||
let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?;
|
||||
let (cloud_username, cloud_password) = resolve_credentials(
|
||||
cloud_kind,
|
||||
args.cloud_username,
|
||||
cloud_password_flag,
|
||||
current_cloud_cred.as_ref(),
|
||||
ni,
|
||||
"Cloud credentials",
|
||||
)?;
|
||||
|
||||
let updated = DrivePair {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
enabled: existing.enabled,
|
||||
context,
|
||||
owner_user,
|
||||
// Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden
|
||||
// eventuell noch aktive Mounts/fstab-Einträge verwaisen.
|
||||
mount_point: existing.mount_point.clone(),
|
||||
local: LocalSide {
|
||||
kind: local_kind,
|
||||
address: local_address,
|
||||
share: local_share,
|
||||
username: local_username.clone(),
|
||||
extra_options: existing.local.extra_options.clone(),
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: cloud_kind,
|
||||
host_or_url: cloud_host,
|
||||
share: cloud_share,
|
||||
username: cloud_username.clone(),
|
||||
extra_options: existing.cloud.extra_options.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
config::pairs::update_pair(updated)?;
|
||||
|
||||
if let Some(pw) = local_password {
|
||||
creds
|
||||
.put(id, Side::Local, local_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
if let Some(pw) = cloud_password {
|
||||
creds
|
||||
.put(id, Side::Cloud, cloud_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
|
||||
println!("Drive pair '{id}' updated.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liest ein Passwort entweder von stdin (eine Zeile, `\r`/`\n` abgeschnitten) oder gibt das
|
||||
/// per Flag übergebene zurück.
|
||||
fn read_password_flag(flag: Option<String>, stdin: bool) -> anyhow::Result<Option<String>> {
|
||||
if stdin {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_line(&mut buf)?;
|
||||
Ok(Some(buf.trim_end_matches(['\n', '\r']).to_string()))
|
||||
} else {
|
||||
Ok(flag)
|
||||
}
|
||||
}
|
||||
|
||||
/// Löst ein einzelnes String-Feld auf: Flag > (nicht-interaktiv: `current`, sonst Fehler bei
|
||||
/// Pflichtfeld) > interaktiver Prompt (vorbelegt mit `current`, falls vorhanden).
|
||||
fn resolve_field(
|
||||
flag: Option<String>,
|
||||
current: Option<&str>,
|
||||
label: &str,
|
||||
non_interactive: bool,
|
||||
required: bool,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if let Some(v) = flag {
|
||||
return Ok(Some(v));
|
||||
}
|
||||
if non_interactive {
|
||||
if required && current.is_none() {
|
||||
anyhow::bail!(
|
||||
"Field '{label}' is missing - specify it via a flag in non-interactive mode."
|
||||
);
|
||||
}
|
||||
return Ok(current.map(str::to_string));
|
||||
}
|
||||
let mut input = Input::<String>::new();
|
||||
input = input.with_prompt(label);
|
||||
if let Some(d) = current {
|
||||
input = input.default(d.to_string());
|
||||
}
|
||||
Ok(Some(input.interact_text()?))
|
||||
}
|
||||
|
||||
fn resolve_context(
|
||||
flag: Option<MountContext>,
|
||||
current: Option<MountContext>,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<MountContext> {
|
||||
if let Some(c) = flag {
|
||||
return Ok(c);
|
||||
}
|
||||
if let Some(c) = current
|
||||
&& non_interactive
|
||||
{
|
||||
return Ok(c);
|
||||
}
|
||||
if non_interactive {
|
||||
anyhow::bail!(
|
||||
"Field 'context' is missing - specify it via '--context system|user' in non-interactive mode."
|
||||
);
|
||||
}
|
||||
let default_idx = if current == Some(MountContext::System) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let idx = Select::new()
|
||||
.with_prompt("Context")
|
||||
.items(["System (root)", "User"])
|
||||
.default(default_idx)
|
||||
.interact()?;
|
||||
Ok(if idx == 0 {
|
||||
MountContext::System
|
||||
} else {
|
||||
MountContext::User
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_owner_user(
|
||||
flag: Option<String>,
|
||||
context: MountContext,
|
||||
current: Option<&str>,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if let Some(v) = flag {
|
||||
return Ok(Some(v));
|
||||
}
|
||||
match context {
|
||||
MountContext::User => {
|
||||
// Pflicht: wird auch für 'setup fstab' (Gruppenmitgliedschaft, Verzeichnis-Owner)
|
||||
// und für die uid=/gid=-Zugriffsrechte benötigt.
|
||||
if non_interactive {
|
||||
return current.map(str::to_string).map(Some).ok_or_else(|| {
|
||||
anyhow::anyhow!("Field 'owner_user' is required for context 'user'.")
|
||||
});
|
||||
}
|
||||
let default_user = current
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
|
||||
Ok(Some(
|
||||
Input::new()
|
||||
.with_prompt("Linux username (owner)")
|
||||
.default(default_user)
|
||||
.interact_text()?,
|
||||
))
|
||||
}
|
||||
MountContext::System => {
|
||||
// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff
|
||||
// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership.
|
||||
if non_interactive {
|
||||
return Ok(current.map(str::to_string));
|
||||
}
|
||||
let want_owner = Confirm::new()
|
||||
.with_prompt("Should a specific user get full access to this drive (uid/gid, including script execution)?")
|
||||
.default(current.is_some())
|
||||
.interact()?;
|
||||
if !want_owner {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut input = Input::<String>::new().with_prompt("Linux username");
|
||||
if let Some(c) = current {
|
||||
input = input.default(c.to_string());
|
||||
}
|
||||
Ok(Some(input.interact_text()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_kind(
|
||||
flag: Option<MountKind>,
|
||||
current: Option<MountKind>,
|
||||
label: &str,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<MountKind> {
|
||||
if let Some(k) = flag {
|
||||
return Ok(k);
|
||||
}
|
||||
if let Some(k) = current
|
||||
&& non_interactive
|
||||
{
|
||||
return Ok(k);
|
||||
}
|
||||
if non_interactive {
|
||||
anyhow::bail!(
|
||||
"Field '{label}' is missing - specify it via a flag in non-interactive mode."
|
||||
);
|
||||
}
|
||||
let default_idx = match current {
|
||||
Some(MountKind::Smb) => 1,
|
||||
Some(MountKind::Nfs) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let idx = Select::new()
|
||||
.with_prompt(label)
|
||||
.items(["WebDAV", "SMB/CIFS", "NFS"])
|
||||
.default(default_idx)
|
||||
.interact()?;
|
||||
Ok(match idx {
|
||||
0 => MountKind::WebDav,
|
||||
1 => MountKind::Smb,
|
||||
_ => MountKind::Nfs,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_local_address(
|
||||
ip_flag: Option<Ipv4Addr>,
|
||||
mac_flag: Option<String>,
|
||||
current: Option<&LocalAddress>,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<LocalAddress> {
|
||||
if let Some(ip) = ip_flag {
|
||||
return Ok(LocalAddress::Ip(ip));
|
||||
}
|
||||
if let Some(mac) = mac_flag {
|
||||
return Ok(LocalAddress::Mac(mac));
|
||||
}
|
||||
if non_interactive {
|
||||
return current.cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("Local address is missing - specify '--local-ip' or '--local-mac'.")
|
||||
});
|
||||
}
|
||||
|
||||
let default_idx = usize::from(matches!(current, Some(LocalAddress::Mac(_))));
|
||||
let idx = Select::new()
|
||||
.with_prompt("Local addressing")
|
||||
.items(["IP address", "MAC address (resolved via mac2ip)"])
|
||||
.default(default_idx)
|
||||
.interact()?;
|
||||
|
||||
if idx == 0 {
|
||||
let mut input = Input::<String>::new().with_prompt("IP address");
|
||||
if let Some(LocalAddress::Ip(ip)) = current {
|
||||
input = input.default(ip.to_string());
|
||||
}
|
||||
let ip_str: String = input.interact_text()?;
|
||||
Ok(LocalAddress::Ip(Ipv4Addr::from_str(&ip_str)?))
|
||||
} else {
|
||||
let mut input = Input::<String>::new().with_prompt("MAC address (e.g. aa:bb:cc:dd:ee:ff)");
|
||||
if let Some(LocalAddress::Mac(mac)) = current {
|
||||
input = input.default(mac.clone());
|
||||
}
|
||||
Ok(LocalAddress::Mac(input.interact_text()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Löst Nutzername/Passwort für eine Seite auf.
|
||||
///
|
||||
/// - Explizit per Flag/stdin gegebenes Passwort wird immer übernommen.
|
||||
/// - Nicht-interaktiv ohne neues Passwort: nur der Nutzername wird ggf. aktualisiert, das
|
||||
/// gespeicherte Passwort bleibt unangetastet (`None` im Rückgabewert = "nicht ändern").
|
||||
/// - Interaktiv beim Bearbeiten (`current.is_some()`): fragt separat, ob das Passwort
|
||||
/// überhaupt geändert werden soll (Standard: nein) - ein Edit erzwingt keine Neueingabe.
|
||||
/// - Interaktiv beim Anlegen: fragt Nutzername+Passwort zusammen ab, sofern der Mount-Typ
|
||||
/// Zugangsdaten braucht (WebDAV/SMB immer, NFS nur auf Wunsch für Kerberos).
|
||||
fn resolve_credentials(
|
||||
kind: MountKind,
|
||||
username_flag: Option<String>,
|
||||
password_flag: Option<String>,
|
||||
current: Option<&Credential>,
|
||||
non_interactive: bool,
|
||||
label: &str,
|
||||
) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||
let current_username = current.and_then(|c| c.username.clone());
|
||||
|
||||
if let Some(pw) = password_flag {
|
||||
let username = username_flag.or(current_username);
|
||||
return Ok((username, Some(pw)));
|
||||
}
|
||||
|
||||
if non_interactive {
|
||||
return Ok((username_flag.or(current_username), None));
|
||||
}
|
||||
|
||||
let is_edit = current.is_some();
|
||||
let needs = match kind {
|
||||
MountKind::WebDav | MountKind::Smb => true,
|
||||
MountKind::Nfs => Confirm::new()
|
||||
.with_prompt(format!("{label}: Kerberos credentials for NFS?"))
|
||||
.default(is_edit)
|
||||
.interact()?,
|
||||
};
|
||||
if !needs {
|
||||
return Ok((username_flag.or(current_username), None));
|
||||
}
|
||||
|
||||
let mut username_input = Input::<String>::new().with_prompt(format!("{label}: username"));
|
||||
if let Some(u) = username_flag.or(current_username) {
|
||||
username_input = username_input.default(u);
|
||||
}
|
||||
let username: String = username_input.interact_text()?;
|
||||
|
||||
if is_edit {
|
||||
let change_password = Confirm::new()
|
||||
.with_prompt(format!("{label}: change password?"))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !change_password {
|
||||
return Ok((Some(username), None));
|
||||
}
|
||||
}
|
||||
|
||||
let password: String = Password::new()
|
||||
.with_prompt(format!("{label}: password"))
|
||||
.with_confirmation("Confirm password", "Passwords do not match")
|
||||
.interact()?;
|
||||
Ok((Some(username), Some(password)))
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
//! `clap`-CLI-Definition und Dispatch.
|
||||
|
||||
pub mod doctor;
|
||||
pub mod drive;
|
||||
pub mod mount_cmd;
|
||||
pub mod service;
|
||||
pub mod setup;
|
||||
pub mod status;
|
||||
pub mod watch;
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::Shell;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "smart-mount",
|
||||
version,
|
||||
about = "Dynamically mounts local/cloud drive pairs and switches between them automatically"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Manage drive pairs (add/edit/remove/list).
|
||||
Drive {
|
||||
#[command(subcommand)]
|
||||
action: Box<drive::DriveAction>,
|
||||
},
|
||||
/// Mount configured drive pairs.
|
||||
Mount {
|
||||
/// Only mount this pair (ID or name).
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Mount all configured pairs.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
},
|
||||
/// Unmount configured drive pairs.
|
||||
Unmount {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
},
|
||||
/// Shows the current mount status.
|
||||
Status {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Output as JSON instead of text - for scripts.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron.
|
||||
Watch,
|
||||
/// Install/remove systemd units.
|
||||
Service {
|
||||
#[command(subcommand)]
|
||||
action: service::ServiceAction,
|
||||
},
|
||||
/// One-time root setup for unprivileged user mounts.
|
||||
Setup {
|
||||
#[command(subcommand)]
|
||||
action: setup::SetupAction,
|
||||
},
|
||||
/// Checks prerequisites (binaries, group membership, fstab setup, scheduler).
|
||||
Doctor {
|
||||
/// Output as JSON instead of text - for scripts.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Prints a shell completion script, e.g.:
|
||||
/// `smart-mount completions bash > /etc/bash_completion.d/smart-mount`.
|
||||
Completions { shell: Shell },
|
||||
}
|
||||
|
||||
/// Führt das per `Cli` geparste Subcommand aus.
|
||||
pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
|
||||
match cli.command {
|
||||
Commands::Drive { action } => drive::run(*action).await,
|
||||
Commands::Mount { name, all } => mount_cmd::run_mount(name, all).await,
|
||||
Commands::Unmount { name, all } => mount_cmd::run_unmount(name, all).await,
|
||||
Commands::Status { name, json } => status::run(name, json).await,
|
||||
Commands::Watch => watch::run().await,
|
||||
Commands::Service { action } => service::run(action),
|
||||
Commands::Setup { action } => setup::run(action),
|
||||
Commands::Doctor { json } => doctor::run(json).await,
|
||||
Commands::Completions { shell } => {
|
||||
clap_complete::generate(
|
||||
shell,
|
||||
&mut Cli::command(),
|
||||
"smart-mount",
|
||||
&mut std::io::stdout(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `smart-mount mount` / `smart-mount unmount`.
|
||||
|
||||
use smart_mount::config::{self, DrivePair, MountContext};
|
||||
use smart_mount::db::credentials::CredentialStore;
|
||||
use smart_mount::reconcile;
|
||||
|
||||
fn select_pairs(
|
||||
cfg: &smart_mount::config::AppConfig,
|
||||
name: Option<&str>,
|
||||
all: bool,
|
||||
) -> anyhow::Result<Vec<DrivePair>> {
|
||||
if let Some(name) = name {
|
||||
return Ok(vec![config::pairs::find_pair(cfg, name)?]);
|
||||
}
|
||||
if !all {
|
||||
anyhow::bail!("Please specify '--name <id>' or '--all'.");
|
||||
}
|
||||
|
||||
let pairs = if sudo_ctdra::is_run_as_root() {
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.filter(|p| p.context == MountContext::System)
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
let user = std::env::var("USER").unwrap_or_default();
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.context == MountContext::User && p.owner_user.as_deref() == Some(user.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
|
||||
if pairs.is_empty() {
|
||||
println!("No matching drive pairs found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let creds = CredentialStore::open().await?;
|
||||
for pair in &pairs {
|
||||
let outcome = reconcile::reconcile_pair(pair, &cfg.settings, &creds).await;
|
||||
print_outcome(&outcome);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
|
||||
if pairs.is_empty() {
|
||||
println!("No matching drive pairs found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for pair in &pairs {
|
||||
match reconcile::unmount_pair(pair, &cfg.settings).await {
|
||||
Ok(_) => println!("{} ({}): unmounted", pair.name, pair.id),
|
||||
Err(e) => println!("{} ({}): ERROR: {e}", pair.name, pair.id),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_outcome(outcome: &reconcile::ReconcileOutcome) {
|
||||
use reconcile::Action;
|
||||
let action_str = match &outcome.action {
|
||||
Action::NoOp => "no change".to_string(),
|
||||
Action::MountedLocal => "mounted local".to_string(),
|
||||
Action::MountedCloud => "mounted cloud".to_string(),
|
||||
Action::SwitchedToLocal => "switched to local".to_string(),
|
||||
Action::SwitchedToCloud => "switched to cloud".to_string(),
|
||||
Action::Failed(e) => format!("ERROR: {e}"),
|
||||
};
|
||||
println!("{} ({}): {action_str}", outcome.pair_name, outcome.pair_id);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! `smart-mount service install|uninstall --system|--user`.
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use smart_mount::config;
|
||||
use smart_mount::fstab;
|
||||
use smart_mount::systemd::{self, Scope};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ServiceAction {
|
||||
/// Sets up periodic execution: systemd if available, otherwise falls back to cron
|
||||
/// automatically (see `crontab`).
|
||||
Install(ScopeArgs),
|
||||
/// Removes everything that `install`/`crontab`/`setup fstab` have set up - systemd
|
||||
/// units, cron entry, and (only for `--system`) the managed `/etc/fstab` block.
|
||||
/// Missing parts are skipped, not treated as an error.
|
||||
Uninstall(ScopeArgs),
|
||||
/// Sets up periodic execution via cron (alternative to `install` for systems without
|
||||
/// systemd) - system or user context is chosen automatically based on the current
|
||||
/// privileges (root -> `/etc/cron.d/smart-mount`, otherwise personal crontab). If no
|
||||
/// cron mechanism is present, the lines for manual entry are printed instead.
|
||||
Crontab,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ScopeArgs {
|
||||
#[arg(long, conflicts_with = "user")]
|
||||
system: bool,
|
||||
#[arg(long, conflicts_with = "system")]
|
||||
user: bool,
|
||||
}
|
||||
|
||||
impl ScopeArgs {
|
||||
fn scope(&self) -> anyhow::Result<Scope> {
|
||||
match (self.system, self.user) {
|
||||
(true, false) => Ok(Scope::System),
|
||||
(false, true) => Ok(Scope::User),
|
||||
_ => anyhow::bail!("Please specify exactly one of '--system' or '--user'."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(action: ServiceAction) -> anyhow::Result<()> {
|
||||
match action {
|
||||
ServiceAction::Install(args) => {
|
||||
let scope = args.scope()?;
|
||||
if scope == Scope::System && !sudo_ctdra::is_run_as_root() {
|
||||
anyhow::bail!(
|
||||
"'service install --system' requires root privileges (re-run with sudo)."
|
||||
);
|
||||
}
|
||||
let cfg = config::pairs::load()?;
|
||||
let interval = cfg.settings.watch_interval_secs;
|
||||
|
||||
if systemd::is_available() {
|
||||
systemd::install(scope, interval)?;
|
||||
println!("systemd units installed and enabled ({scope:?}).");
|
||||
} else {
|
||||
println!("systemd not found - setting up cron instead.");
|
||||
match systemd::install_cron(scope, interval)? {
|
||||
systemd::CronInstallOutcome::SystemFile(path) => {
|
||||
println!("Cron entry written: {}", path.display());
|
||||
}
|
||||
systemd::CronInstallOutcome::UserCrontab => {
|
||||
println!("Personal crontab updated (see 'crontab -l').");
|
||||
}
|
||||
systemd::CronInstallOutcome::Unavailable => {
|
||||
println!(
|
||||
"Neither systemd nor cron found - here are the lines for manual entry:"
|
||||
);
|
||||
print!("{}", systemd::crontab_equivalent(interval));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ServiceAction::Uninstall(args) => {
|
||||
let scope = args.scope()?;
|
||||
if scope == Scope::System && !sudo_ctdra::is_run_as_root() {
|
||||
anyhow::bail!(
|
||||
"'service uninstall --system' requires root privileges (re-run with sudo)."
|
||||
);
|
||||
}
|
||||
|
||||
let mut removed = Vec::new();
|
||||
|
||||
if systemd::is_available() {
|
||||
match systemd::uninstall(scope)? {
|
||||
systemd::SystemdUninstallOutcome::Removed => removed.push("systemd units"),
|
||||
systemd::SystemdUninstallOutcome::NotPresent => {}
|
||||
}
|
||||
}
|
||||
|
||||
match systemd::uninstall_cron(scope)? {
|
||||
systemd::CronUninstallOutcome::Removed => removed.push("cron entry"),
|
||||
systemd::CronUninstallOutcome::NotPresent => {}
|
||||
}
|
||||
|
||||
// fstab-Einträge sind unabhängig vom --system/--user-Scope des Aufrufers immer
|
||||
// root-weit (setup() betrifft alle User-Kontext-Paare) - nur bei --system mit
|
||||
// aufräumen, damit ein `--user`-Uninstall nicht versehentlich Root-Konfiguration
|
||||
// anfasst, die ein anderer Nutzer noch braucht.
|
||||
if scope == Scope::System {
|
||||
match fstab::teardown()? {
|
||||
fstab::FstabTeardownOutcome::Removed => removed.push("fstab entries"),
|
||||
fstab::FstabTeardownOutcome::NotPresent => {}
|
||||
}
|
||||
}
|
||||
|
||||
if removed.is_empty() {
|
||||
println!("Nothing to remove - nothing was installed ({scope:?}).");
|
||||
} else {
|
||||
println!("Removed ({scope:?}): {}", removed.join(", "));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ServiceAction::Crontab => {
|
||||
let cfg = config::pairs::load()?;
|
||||
let interval = cfg.settings.watch_interval_secs;
|
||||
// Scope folgt automatisch den aktuellen Rechten, wie bei `mount --all` -
|
||||
// root pflegt den systemweiten Cron-Eintrag, ein normaler Nutzer seine eigene
|
||||
// Crontab. Anders als bei `install`/`uninstall` gibt es hier bewusst keine
|
||||
// expliziten `--system`/`--user`-Flags, weil die Wahl ohnehin durch die Rechte
|
||||
// vorgegeben ist (root kann nicht "versehentlich" die falsche Crontab treffen).
|
||||
let scope = if sudo_ctdra::is_run_as_root() {
|
||||
Scope::System
|
||||
} else {
|
||||
Scope::User
|
||||
};
|
||||
|
||||
match systemd::install_cron(scope, interval)? {
|
||||
systemd::CronInstallOutcome::SystemFile(path) => {
|
||||
println!("Cron entry written: {}", path.display());
|
||||
}
|
||||
systemd::CronInstallOutcome::UserCrontab => {
|
||||
println!("Personal crontab updated (see 'crontab -l').");
|
||||
}
|
||||
systemd::CronInstallOutcome::Unavailable => {
|
||||
println!(
|
||||
"No cron mechanism found ({}) - here are the lines for manual entry:",
|
||||
if scope == Scope::System {
|
||||
"/etc/cron.d is missing"
|
||||
} else {
|
||||
"'crontab' not in PATH"
|
||||
}
|
||||
);
|
||||
print!("{}", systemd::crontab_equivalent(interval));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! `smart-mount setup fstab`.
|
||||
|
||||
use clap::Subcommand;
|
||||
use smart_mount::fstab;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SetupAction {
|
||||
/// One-time root setup: `/etc/fstab` entries + group membership for
|
||||
/// unprivileged user mounts.
|
||||
Fstab,
|
||||
}
|
||||
|
||||
pub fn run(action: SetupAction) -> anyhow::Result<()> {
|
||||
match action {
|
||||
SetupAction::Fstab => {
|
||||
fstab::setup()?;
|
||||
println!("fstab setup complete.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `smart-mount status`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::db::credentials::Side;
|
||||
use smart_mount::mount::target;
|
||||
use smart_mount::network;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PairStatus {
|
||||
id: String,
|
||||
name: String,
|
||||
mount_point: String,
|
||||
active: Option<&'static str>,
|
||||
local_source: String,
|
||||
local_reachable: bool,
|
||||
cloud_source: String,
|
||||
cloud_reachable: bool,
|
||||
}
|
||||
|
||||
pub async fn run(name: Option<String>, json: bool) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = match &name {
|
||||
Some(name) => vec![config::pairs::find_pair(&cfg, name)?],
|
||||
None => cfg.pairs.clone(),
|
||||
};
|
||||
|
||||
if pairs.is_empty() {
|
||||
if json {
|
||||
println!("[]");
|
||||
} else {
|
||||
println!("No drive pairs configured.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let statuses: Vec<PairStatus> = pairs
|
||||
.iter()
|
||||
.map(|pair| {
|
||||
let active = match target::active_side(pair) {
|
||||
Some(Side::Local) => Some("local"),
|
||||
Some(Side::Cloud) => Some("cloud"),
|
||||
None => None,
|
||||
};
|
||||
PairStatus {
|
||||
id: pair.id.clone(),
|
||||
name: pair.name.clone(),
|
||||
mount_point: pair.mount_point.display().to_string(),
|
||||
active,
|
||||
local_source: target::local_source(&pair.local, &cfg.settings),
|
||||
local_reachable: network::is_reachable(&local_src_host(&pair.local, &cfg.settings)),
|
||||
cloud_source: target::cloud_source(&pair.cloud),
|
||||
cloud_reachable: network::is_reachable(&pair.cloud.host_or_url),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&statuses)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for status in &statuses {
|
||||
println!("{} ({})", status.name, status.id);
|
||||
println!(" Symlink: {}", status.mount_point);
|
||||
println!(" Mounted: {}", status.active.unwrap_or("not mounted"));
|
||||
println!(
|
||||
" Local: {} [{}]",
|
||||
status.local_source,
|
||||
reachable_str(status.local_reachable)
|
||||
);
|
||||
println!(
|
||||
" Cloud: {} [{}]",
|
||||
status.cloud_source,
|
||||
reachable_str(status.cloud_reachable)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reachable_str(reachable: bool) -> &'static str {
|
||||
if reachable {
|
||||
"reachable"
|
||||
} else {
|
||||
"unreachable"
|
||||
}
|
||||
}
|
||||
|
||||
fn local_src_host(
|
||||
local: &smart_mount::config::LocalSide,
|
||||
settings: &smart_mount::config::GlobalSettings,
|
||||
) -> String {
|
||||
smart_mount::network::address::resolve_ip(&local.address, settings)
|
||||
.map(|ip| ip.to_string())
|
||||
.unwrap_or_else(|_| "unresolved".to_string())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `smart-mount watch` - ein einzelner Reconcile-Durchlauf, gedacht für systemd-Timer/Cron.
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::db::credentials::CredentialStore;
|
||||
use smart_mount::reconcile::{self, Action};
|
||||
|
||||
use crate::cli::mount_cmd::print_outcome;
|
||||
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let creds = CredentialStore::open().await?;
|
||||
let outcomes = reconcile::watch_once(&cfg, &creds).await;
|
||||
|
||||
let mut had_failure = false;
|
||||
for outcome in &outcomes {
|
||||
print_outcome(outcome);
|
||||
if matches!(outcome.action, Action::Failed(_)) {
|
||||
had_failure = true;
|
||||
}
|
||||
}
|
||||
|
||||
if had_failure {
|
||||
anyhow::bail!("at least one drive pair could not be reconciled");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Konfigurationsverwaltung: Schema + CRUD auf Laufwerkspaaren, aufbauend auf `config-ctdra`.
|
||||
|
||||
pub mod pairs;
|
||||
pub mod schema;
|
||||
|
||||
pub use schema::{
|
||||
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountContext,
|
||||
MountKind,
|
||||
};
|
||||
|
||||
/// Initialisiert den Konfigurationsdateinamen bei `config-ctdra`. Muss vor dem ersten
|
||||
/// `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter Zustand).
|
||||
pub fn init() {
|
||||
config_ctdra::set_config_name("config");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! CRUD-Operationen auf [`AppConfig::pairs`], atomar über `config_ctdra::modify`.
|
||||
|
||||
use crate::config::schema::{AppConfig, DrivePair};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Lädt die aktuelle Konfiguration frisch von der Platte.
|
||||
pub fn load() -> Result<AppConfig> {
|
||||
Ok(config_ctdra::load::<AppConfig>()?)
|
||||
}
|
||||
|
||||
/// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt).
|
||||
pub fn add_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
Ok(config_ctdra::modify::<AppConfig, _>(|cfg| {
|
||||
cfg.pairs.push(pair.clone());
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`).
|
||||
pub fn update_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
let id = pair.id.clone();
|
||||
let updated = config_ctdra::modify::<AppConfig, _>(move |cfg| {
|
||||
if let Some(existing) = cfg.pairs.iter_mut().find(|p| p.id == pair.id) {
|
||||
*existing = pair.clone();
|
||||
}
|
||||
})?;
|
||||
if !updated.pairs.iter().any(|p| p.id == id) {
|
||||
return Err(Error::PairNotFound(id));
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Entfernt ein Laufwerkspaar per ID.
|
||||
pub fn remove_pair(id: &str) -> Result<AppConfig> {
|
||||
let before_len = load()?.pairs.len();
|
||||
let updated = config_ctdra::modify::<AppConfig, _>(|cfg| {
|
||||
cfg.pairs.retain(|p| p.id != id);
|
||||
})?;
|
||||
if updated.pairs.len() == before_len {
|
||||
return Err(Error::PairNotFound(id.to_string()));
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Sucht ein Laufwerkspaar per ID oder (fallback) exaktem Namen.
|
||||
pub fn find_pair(cfg: &AppConfig, id_or_name: &str) -> Result<DrivePair> {
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.find(|p| p.id == id_or_name || p.name == id_or_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::PairNotFound(id_or_name.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Konfigurationsschema: globale Einstellungen + Liste konfigurierter Laufwerkspaare.
|
||||
//!
|
||||
//! Passwörter sind hier bewusst NICHT enthalten - sie leben verschlüsselt in der
|
||||
//! Turso-Datenbank (siehe [`crate::db::credentials`]), niemals im Klartext in der TOML-Datei.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene
|
||||
/// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei
|
||||
/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie
|
||||
/// persistieren müssen. Root-/System-Kontext-Paare landen flach unter `/run/media/smart-mount`
|
||||
/// (ein systemweiter Dienst, keinem einzelnen Nutzer zugeordnet); Nutzer-Kontext-Paare unter
|
||||
/// `/run/media/<Nutzer>/smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich
|
||||
/// nicht denselben Namensraum teilen.
|
||||
fn default_mount_base_dir() -> PathBuf {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
PathBuf::from("/run/media/smart-mount")
|
||||
} else {
|
||||
let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
|
||||
PathBuf::from("/run/media").join(user).join("smart-mount")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
fn default_watch_interval_secs() -> u64 {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_mac2ip_binary() -> String {
|
||||
"mac2ip".to_string()
|
||||
}
|
||||
|
||||
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` unter
|
||||
/// `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. `/etc/smart-mount/config.toml`
|
||||
/// (Root-Kontext).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub settings: GlobalSettings,
|
||||
#[serde(default)]
|
||||
pub pairs: Vec<DrivePair>,
|
||||
}
|
||||
|
||||
/// Globale, paarunabhängige Einstellungen.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GlobalSettings {
|
||||
#[serde(default = "default_mount_base_dir")]
|
||||
pub mount_base_dir: PathBuf,
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: String,
|
||||
/// Periode, mit der `smart-mount watch` über den generierten systemd-Timer bzw. die
|
||||
/// dokumentierte Crontab-Zeile ausgeführt werden soll.
|
||||
#[serde(default = "default_watch_interval_secs")]
|
||||
pub watch_interval_secs: u64,
|
||||
/// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad).
|
||||
#[serde(default = "default_mac2ip_binary")]
|
||||
pub mac2ip_binary: String,
|
||||
}
|
||||
|
||||
impl Default for GlobalSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mount_base_dir: default_mount_base_dir(),
|
||||
log_level: default_log_level(),
|
||||
watch_interval_secs: default_watch_interval_secs(),
|
||||
mac2ip_binary: default_mac2ip_binary(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein konfiguriertes Laufwerkspaar: eine lokale (LAN) und eine Cloud-Seite, die dasselbe
|
||||
/// logische Laufwerk repräsentieren. Nur eine Seite ist zu einem Zeitpunkt an `mount_point`
|
||||
/// eingebunden - siehe [`crate::reconcile`].
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DrivePair {
|
||||
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten, Mount-Unterverzeichnis,
|
||||
/// systemd-Unit-Namen und fstab-Einträge.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
pub context: MountContext,
|
||||
/// Pflicht bei `context == User`: der Linux-Benutzername, dem dieses Paar gehört.
|
||||
#[serde(default)]
|
||||
pub owner_user: Option<String>,
|
||||
pub mount_point: PathBuf,
|
||||
pub local: LocalSide,
|
||||
pub cloud: CloudSide,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Ob ein Laufwerkspaar systemweit (root, `/etc/fstab`+systemd-System-Service) oder als
|
||||
/// einzelner Nutzer (`systemd --user`, unprivilegiert über `setup fstab`) eingebunden wird.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[value(rename_all = "lowercase")]
|
||||
pub enum MountContext {
|
||||
System,
|
||||
User,
|
||||
}
|
||||
|
||||
/// Die lokale (LAN-)Seite eines Laufwerkspaars.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LocalSide {
|
||||
pub kind: MountKind,
|
||||
pub address: LocalAddress,
|
||||
/// Freigabename/Exportpfad (SMB-Share, NFS-Export, WebDAV-Pfadsegment).
|
||||
pub share: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_options: Vec<String>,
|
||||
}
|
||||
|
||||
/// Die Cloud-Seite eines Laufwerkspaars.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CloudSide {
|
||||
pub kind: MountKind,
|
||||
/// Volle URL (WebDAV) bzw. Server-Adresse (SMB/NFS).
|
||||
pub host_or_url: String,
|
||||
pub share: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_options: Vec<String>,
|
||||
}
|
||||
|
||||
/// Unterstützte Mount-Verfahren. Jede Variante wird dynamisch auf ein
|
||||
/// [`crate::mount::MountBackend`] dispatcht - siehe dort für die "nur bei Bedarf laden"-Logik.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, clap::ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[value(rename_all = "lowercase")]
|
||||
pub enum MountKind {
|
||||
WebDav,
|
||||
Smb,
|
||||
Nfs,
|
||||
}
|
||||
|
||||
impl MountKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MountKind::WebDav => "webdav",
|
||||
MountKind::Smb => "smb",
|
||||
MountKind::Nfs => "nfs",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adressierung der lokalen Seite: entweder direkt per IP oder per MAC-Adresse, die zur
|
||||
/// Laufzeit über `mac2ip` aufgelöst wird (siehe [`crate::network::mac2ip`]).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LocalAddress {
|
||||
Ip(Ipv4Addr),
|
||||
Mac(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_config_round_trips_through_toml() {
|
||||
let cfg = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "NAS".into(),
|
||||
enabled: true,
|
||||
context: MountContext::User,
|
||||
owner_user: Some("dragon".into()),
|
||||
mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"),
|
||||
local: LocalSide {
|
||||
kind: MountKind::WebDav,
|
||||
address: LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into()),
|
||||
share: "/dav".into(),
|
||||
username: Some("nasuser".into()),
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: MountKind::WebDav,
|
||||
host_or_url: "https://cloud.example.com/remote.php/dav/files/dragon".into(),
|
||||
share: "/".into(),
|
||||
username: Some("dragon".into()),
|
||||
extra_options: vec![],
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&cfg).expect("serialize");
|
||||
let round_tripped: AppConfig = toml::from_str(&toml_str).expect("deserialize");
|
||||
|
||||
assert_eq!(round_tripped.pairs.len(), 1);
|
||||
assert_eq!(round_tripped.pairs[0].id, "pair-1");
|
||||
assert_eq!(round_tripped.pairs[0].context, MountContext::User);
|
||||
match &round_tripped.pairs[0].local.address {
|
||||
LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"),
|
||||
LocalAddress::Ip(_) => panic!("expected Mac variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mount_base_dir_uses_run_media() {
|
||||
// Tests laufen nie als root, daher greift hier immer der Nutzerkontext-Zweig.
|
||||
let dir = default_mount_base_dir();
|
||||
assert!(dir.starts_with("/run/media"));
|
||||
assert!(dir.ends_with("smart-mount"));
|
||||
if let Ok(user) = std::env::var("USER") {
|
||||
assert!(dir.to_string_lossy().contains(&user));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung.
|
||||
//!
|
||||
//! Reihenfolge (wie mit dem Nutzer abgestimmt):
|
||||
//! - Root-/System-Kontext: **immer** die Schlüsseldatei (kein Nutzer-Keyring im
|
||||
//! Systemdienst-Kontext verfügbar).
|
||||
//! - Nutzerkontext: zuerst das OS-Keyring (GNOME Keyring/KWallet über secret-service)
|
||||
//! versuchen, bei Nichtverfügbarkeit (z. B. Headless-Server, kein D-Bus-Secret-Service)
|
||||
//! transparent auf die Schlüsseldatei zurückfallen.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const KEYRING_SERVICE: &str = "smart-mount";
|
||||
const KEYRING_USERNAME: &str = "master-key";
|
||||
const KEY_FILE_NAME: &str = "master.key";
|
||||
const KEY_LEN: usize = 32;
|
||||
|
||||
/// Ermittelt (und erzeugt bei Bedarf) den 256-Bit-Master-Schlüssel für die
|
||||
/// Zugangsdaten-Verschlüsselung, siehe Modul-Dokumentation für die Fallback-Reihenfolge.
|
||||
pub fn resolve_master_key() -> Result<[u8; 32]> {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
return file_key::load_or_create(&key_file_path());
|
||||
}
|
||||
|
||||
match keyring_key::load_or_create() {
|
||||
Ok(key) => Ok(key),
|
||||
Err(reason) => {
|
||||
logger_ctdra::warn(
|
||||
"crypto",
|
||||
&format!("OS keyring not available ({reason}), using key file"),
|
||||
);
|
||||
file_key::load_or_create(&key_file_path())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_file_path() -> PathBuf {
|
||||
let config_path = config_ctdra::get_config_path();
|
||||
config_path
|
||||
.parent()
|
||||
.map(|dir| dir.join(KEY_FILE_NAME))
|
||||
.unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME))
|
||||
}
|
||||
|
||||
mod file_key {
|
||||
use super::*;
|
||||
|
||||
pub fn load_or_create(path: &Path) -> Result<[u8; 32]> {
|
||||
if path.exists() {
|
||||
return read(path);
|
||||
}
|
||||
create(path)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Result<[u8; 32]> {
|
||||
let mut file = File::open(path).map_err(|e| Error::io(path, e))?;
|
||||
let mut buf = Vec::with_capacity(KEY_LEN);
|
||||
file.read_to_end(&mut buf).map_err(|e| Error::io(path, e))?;
|
||||
if buf.len() != KEY_LEN {
|
||||
return Err(Error::Crypto(format!(
|
||||
"key file '{}' has unexpected length ({} instead of {KEY_LEN} bytes)",
|
||||
path.display(),
|
||||
buf.len()
|
||||
)));
|
||||
}
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
key.copy_from_slice(&buf);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn create(path: &Path) -> Result<[u8; 32]> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
fill_random(&mut key)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut opts = OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let mut opts = OpenOptions::new();
|
||||
|
||||
let mut file = opts
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
file.write_all(&key).map_err(|e| Error::io(path, e))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn fill_random(buf: &mut [u8]) -> Result<()> {
|
||||
::getrandom::fill(buf)
|
||||
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
mod keyring_key {
|
||||
use super::*;
|
||||
|
||||
pub fn load_or_create() -> std::result::Result<[u8; 32], String> {
|
||||
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME)
|
||||
.map_err(|e| format!("Could not create keyring entry: {e}"))?;
|
||||
|
||||
match entry.get_password() {
|
||||
Ok(hex_key) => decode(&hex_key),
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
let key = generate()?;
|
||||
entry
|
||||
.set_password(&encode(&key))
|
||||
.map_err(|e| format!("Could not store key in keyring: {e}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => Err(format!("Keyring access failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate() -> std::result::Result<[u8; 32], String> {
|
||||
let mut key = [0u8; 32];
|
||||
::getrandom::fill(&mut key).map_err(|e| format!("Random number generator failed: {e}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn encode(key: &[u8; 32]) -> String {
|
||||
key.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn decode(hex_key: &str) -> std::result::Result<[u8; 32], String> {
|
||||
if hex_key.len() != 64 {
|
||||
return Err(format!(
|
||||
"unexpected key length in keyring ({} instead of 64 hex characters)",
|
||||
hex_key.len()
|
||||
));
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
for (i, chunk) in hex_key.as_bytes().chunks(2).enumerate() {
|
||||
let byte_str = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
|
||||
key[i] = u8::from_str_radix(byte_str, 16).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Verschlüsselung von Zugangsdaten vor der Ablage in der Turso-Datenbank.
|
||||
//!
|
||||
//! Turso hat aktuell keine produktionsreife eingebaute Verschlüsselung (nur ein
|
||||
//! experimentelles, unauditiertes Feature) - Passwörter werden daher hier selbst mit
|
||||
//! AES-256-GCM verschlüsselt, bevor sie als Ciphertext-BLOB in die DB geschrieben werden.
|
||||
|
||||
pub mod key;
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
// `aead::Nonce<A>` ist über den AEAD-Algorithmus-Typ parametrisiert (löst intern
|
||||
// `<A as AeadCore>::NonceSize` auf) - anders als `aes_gcm::Nonce<NonceSize>`, das direkt
|
||||
// über die Array-Länge parametrisiert ist. Für `Nonce::<Aes256Gcm>` brauchen wir Ersteres.
|
||||
use aes_gcm::aead::Nonce;
|
||||
use aes_gcm::{Aes256Gcm, Key};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// AES-GCM-Nonce-Länge in Byte (96 Bit, Standard für AES-256-GCM).
|
||||
pub const NONCE_LEN: usize = 12;
|
||||
|
||||
/// Verschlüsselt `plaintext` mit dem gegebenen 256-Bit-Schlüssel.
|
||||
///
|
||||
/// Gibt `(ciphertext, nonce)` zurück - beide werden zusammen mit dem Datensatz gespeichert;
|
||||
/// der Schlüssel selbst wird niemals in der Datenbank abgelegt.
|
||||
///
|
||||
/// Erzeugt die Nonce bewusst über eine eigene, direkte `getrandom`-Abhängigkeit statt über
|
||||
/// `aes_gcm::aead::rand_core::OsRng` - Letzteres ist seit aes-gcm 0.11 nicht mehr ohne
|
||||
/// Weiteres erreichbar (rand_core 0.9s `OsRng` steckt hinter dem `os_rng`-Feature, das über
|
||||
/// aes-gcms Re-Export-Kette nicht automatisch aktiviert wird). `getrandom::fill` ist
|
||||
/// unabhängig davon stabil und genau für diesen Zweck gedacht.
|
||||
pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
getrandom::fill(&mut nonce_bytes)
|
||||
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))?;
|
||||
let nonce: Nonce<Aes256Gcm> = nonce_bytes.into();
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|e| Error::Crypto(format!("Encryption failed: {e}")))?;
|
||||
Ok((ciphertext, nonce.to_vec()))
|
||||
}
|
||||
|
||||
/// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext.
|
||||
pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
if nonce.len() != NONCE_LEN {
|
||||
return Err(Error::Crypto(format!(
|
||||
"invalid nonce length: expected {NONCE_LEN}, got {}",
|
||||
nonce.len()
|
||||
)));
|
||||
}
|
||||
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
|
||||
let nonce: Nonce<Aes256Gcm> = Nonce::<Aes256Gcm>::try_from(nonce)
|
||||
.map_err(|_| Error::Crypto("invalid nonce".to_string()))?;
|
||||
cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = [7u8; 32];
|
||||
let plaintext = b"correct horse battery staple";
|
||||
|
||||
let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt");
|
||||
assert_ne!(ciphertext, plaintext);
|
||||
|
||||
let decrypted = decrypt(&ciphertext, &nonce, &key).expect("decrypt");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_fails_with_wrong_key() {
|
||||
let key = [1u8; 32];
|
||||
let other_key = [2u8; 32];
|
||||
let (ciphertext, nonce) = encrypt(b"secret", &key).expect("encrypt");
|
||||
assert!(decrypt(&ciphertext, &nonce, &other_key).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Verschlüsselte Zugangsdaten-CRUD auf der `credentials`-Tabelle.
|
||||
|
||||
use crate::crypto;
|
||||
use crate::crypto::key::resolve_master_key;
|
||||
use crate::error::Result;
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Welche Seite eines Laufwerkspaars die Zugangsdaten betreffen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Local,
|
||||
Cloud,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Side::Local => "local",
|
||||
Side::Cloud => "cloud",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entschlüsselte Zugangsdaten für eine Seite eines Laufwerkspaars.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Credential {
|
||||
pub username: Option<String>,
|
||||
pub domain: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Dünner Wrapper um die `credentials`-Tabelle; ver-/entschlüsselt transparent mit dem
|
||||
/// per [`resolve_master_key`] ermittelten Schlüssel.
|
||||
pub struct CredentialStore {
|
||||
conn: turso::Connection,
|
||||
}
|
||||
|
||||
impl CredentialStore {
|
||||
/// Öffnet die Datenbank und initialisiert das Schema bei Bedarf.
|
||||
pub async fn open() -> Result<Self> {
|
||||
Ok(Self {
|
||||
conn: crate::db::open().await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Speichert (oder ersetzt) die Zugangsdaten für `pair_id`/`side`.
|
||||
pub async fn put(
|
||||
&self,
|
||||
pair_id: &str,
|
||||
side: Side,
|
||||
username: Option<&str>,
|
||||
domain: Option<&str>,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
let key = resolve_master_key()?;
|
||||
let (ciphertext, nonce) = crypto::encrypt(password.as_bytes(), &key)?;
|
||||
let now = now_unix();
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO credentials (pair_id, side, username, domain, ciphertext, nonce, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
|
||||
ON CONFLICT(pair_id, side) DO UPDATE SET \
|
||||
username = excluded.username, domain = excluded.domain, \
|
||||
ciphertext = excluded.ciphertext, nonce = excluded.nonce, updated_at = excluded.updated_at",
|
||||
(
|
||||
pair_id.to_string(),
|
||||
side.as_str().to_string(),
|
||||
username.map(str::to_string),
|
||||
domain.map(str::to_string),
|
||||
ciphertext,
|
||||
nonce,
|
||||
now,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liest und entschlüsselt die Zugangsdaten für `pair_id`/`side`, falls vorhanden.
|
||||
pub async fn get(&self, pair_id: &str, side: Side) -> Result<Option<Credential>> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
.query(
|
||||
"SELECT username, domain, ciphertext, nonce FROM credentials WHERE pair_id = ?1 AND side = ?2",
|
||||
(pair_id.to_string(), side.as_str().to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(row) = rows.next().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let username: Option<String> = row.get(0)?;
|
||||
let domain: Option<String> = row.get(1)?;
|
||||
let ciphertext: Vec<u8> = row.get(2)?;
|
||||
let nonce: Vec<u8> = row.get(3)?;
|
||||
|
||||
let key = resolve_master_key()?;
|
||||
let plaintext = crypto::decrypt(&ciphertext, &nonce, &key)?;
|
||||
let password = String::from_utf8(plaintext).map_err(|e| {
|
||||
crate::error::Error::Crypto(format!("password is not valid UTF-8: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(Some(Credential {
|
||||
username,
|
||||
domain,
|
||||
password,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Löscht Zugangsdaten. `side = None` löscht beide Seiten (z. B. beim Entfernen eines Paars).
|
||||
pub async fn delete(&self, pair_id: &str, side: Option<Side>) -> Result<()> {
|
||||
match side {
|
||||
Some(side) => {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM credentials WHERE pair_id = ?1 AND side = ?2",
|
||||
(pair_id.to_string(), side.as_str().to_string()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM credentials WHERE pair_id = ?1",
|
||||
(pair_id.to_string(),),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Lokale Turso-Datenbank für verschlüsselt gespeicherte Zugangsdaten.
|
||||
|
||||
pub mod credentials;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const DB_FILE_NAME: &str = "smart-mount.db";
|
||||
|
||||
/// Pfad zur Datenbankdatei: dasselbe Verzeichnis wie die Konfigurationsdatei, folgt also
|
||||
/// automatisch derselben Root-/User-Auflösung wie `config-ctdra`.
|
||||
pub fn resolve_db_path() -> PathBuf {
|
||||
let config_path = config_ctdra::get_config_path();
|
||||
config_path
|
||||
.parent()
|
||||
.map(|dir| dir.join(DB_FILE_NAME))
|
||||
.unwrap_or_else(|| PathBuf::from(DB_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Öffnet (und initialisiert bei Bedarf) die lokale Datenbank am aufgelösten Pfad.
|
||||
pub async fn open() -> Result<turso::Connection> {
|
||||
let path = resolve_db_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
.map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
let db = turso::Builder::new_local(path.to_string_lossy().as_ref())
|
||||
.build()
|
||||
.await?;
|
||||
let conn = db.connect()?;
|
||||
init_schema(&conn).await?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
async fn init_schema(conn: &turso::Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS credentials (\
|
||||
pair_id TEXT NOT NULL, \
|
||||
side TEXT NOT NULL CHECK(side IN ('local','cloud')), \
|
||||
username TEXT, \
|
||||
domain TEXT, \
|
||||
ciphertext BLOB NOT NULL, \
|
||||
nonce BLOB NOT NULL, \
|
||||
updated_at INTEGER NOT NULL, \
|
||||
PRIMARY KEY (pair_id, side)\
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
//! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung
|
||||
//! angesammelten Voraussetzungen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler)
|
||||
//! gebündelt an einer Stelle, statt sie einzeln erst beim Mount-Fehlschlag zu entdecken.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{AppConfig, DrivePair, LocalAddress, MountContext, MountKind};
|
||||
use crate::mount;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CheckStatus {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CheckResult {
|
||||
pub label: String,
|
||||
pub status: CheckStatus,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
fn ok(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
|
||||
CheckResult {
|
||||
label: label.into(),
|
||||
status: CheckStatus::Ok,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn warn(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
|
||||
CheckResult {
|
||||
label: label.into(),
|
||||
status: CheckStatus::Warn,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn fail(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
|
||||
CheckResult {
|
||||
label: label.into(),
|
||||
status: CheckStatus::Fail,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Führt alle Checks gegen die aktuelle Konfiguration aus.
|
||||
pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
results.push(check_timeout_binary());
|
||||
results.push(check_scheduler());
|
||||
results.push(check_mount_base_dir(&cfg.settings.mount_base_dir));
|
||||
|
||||
let used_kinds = used_mount_kinds(cfg);
|
||||
for kind in [MountKind::WebDav, MountKind::Smb, MountKind::Nfs] {
|
||||
if used_kinds.contains(&kind) {
|
||||
results.push(check_backend(kind));
|
||||
}
|
||||
}
|
||||
|
||||
if uses_mac_addressing(cfg) {
|
||||
results.push(check_binary(
|
||||
"mac2ip",
|
||||
&cfg.settings.mac2ip_binary,
|
||||
"install mac2ip (private tool, see README)",
|
||||
));
|
||||
results.push(check_binary("nmap", "nmap", "install package 'nmap'"));
|
||||
}
|
||||
|
||||
if cfg.pairs.iter().any(|p| p.context == MountContext::User) {
|
||||
results.push(check_fstab_setup(cfg));
|
||||
if used_kinds.contains(&MountKind::WebDav) {
|
||||
results.push(check_davfs2_group_membership(cfg));
|
||||
}
|
||||
}
|
||||
|
||||
for pair in &cfg.pairs {
|
||||
if pair.context == MountContext::User && pair.owner_user.is_none() {
|
||||
results.push(fail(
|
||||
format!("Pair '{}': owner_user", pair.name),
|
||||
"context 'user', but no owner_user set - 'setup fstab' will reject this."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn used_mount_kinds(cfg: &AppConfig) -> HashSet<MountKind> {
|
||||
let mut set = HashSet::new();
|
||||
for pair in &cfg.pairs {
|
||||
set.insert(pair.local.kind);
|
||||
set.insert(pair.cloud.kind);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn uses_mac_addressing(cfg: &AppConfig) -> bool {
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.any(|p| matches!(p.local.address, LocalAddress::Mac(_)))
|
||||
}
|
||||
|
||||
fn check_timeout_binary() -> CheckResult {
|
||||
if mount::binary_available("timeout") {
|
||||
ok(
|
||||
"timeout (coreutils)",
|
||||
"found - protects mount/umount against hanging indefinitely",
|
||||
)
|
||||
} else {
|
||||
fail(
|
||||
"timeout (coreutils)",
|
||||
"not found - mount/umount calls would block indefinitely, should be present on every Linux system",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_binary(name: &str, binary: &str, install_hint: &str) -> CheckResult {
|
||||
if mount::binary_available(binary) {
|
||||
ok(name, format!("'{binary}' found"))
|
||||
} else {
|
||||
fail(name, format!("'{binary}' not found - {install_hint}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn check_backend(kind: MountKind) -> CheckResult {
|
||||
let backend = mount::backend_for(kind);
|
||||
match backend.check_available() {
|
||||
Ok(()) => ok(format!("Backend: {}", backend.name()), "available"),
|
||||
Err(e) => fail(format!("Backend: {}", backend.name()), e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_scheduler() -> CheckResult {
|
||||
let systemd = crate::systemd::is_available();
|
||||
let cron_d = std::path::Path::new("/etc/cron.d").is_dir();
|
||||
let crontab = mount::binary_available("crontab");
|
||||
|
||||
if systemd {
|
||||
ok(
|
||||
"Scheduler",
|
||||
"systemd found - 'smart-mount service install' uses systemd timers",
|
||||
)
|
||||
} else if cron_d || crontab {
|
||||
warn(
|
||||
"Scheduler",
|
||||
"no systemd, but cron found - 'smart-mount service install' automatically falls back to cron",
|
||||
)
|
||||
} else {
|
||||
fail(
|
||||
"Scheduler",
|
||||
"neither systemd nor cron found - periodic 'watch' must be set up manually",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult {
|
||||
if dir.is_dir() {
|
||||
ok("mount_base_dir", format!("'{}' exists", dir.display()))
|
||||
} else if dir.parent().is_some_and(std::path::Path::is_dir) {
|
||||
warn(
|
||||
"mount_base_dir",
|
||||
format!(
|
||||
"'{}' does not exist yet, will be created on first mount",
|
||||
dir.display()
|
||||
),
|
||||
)
|
||||
} else {
|
||||
fail(
|
||||
"mount_base_dir",
|
||||
format!(
|
||||
"'{}' does not exist and its parent directory is also missing",
|
||||
dir.display()
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_fstab_setup(cfg: &AppConfig) -> CheckResult {
|
||||
let existing = std::fs::read_to_string("/etc/fstab").unwrap_or_default();
|
||||
if existing.contains("# BEGIN smart-mount managed block") {
|
||||
ok("setup fstab", "managed block found in /etc/fstab")
|
||||
} else {
|
||||
let count = cfg
|
||||
.pairs
|
||||
.iter()
|
||||
.filter(|p| p.context == MountContext::User)
|
||||
.count();
|
||||
fail(
|
||||
"setup fstab",
|
||||
format!(
|
||||
"no managed block found in /etc/fstab, but {count} user-context pair(s) configured - run 'sudo smart-mount setup fstab'"
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_davfs2_group_membership(cfg: &AppConfig) -> CheckResult {
|
||||
let owners: HashSet<&str> = cfg
|
||||
.pairs
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.context == MountContext::User
|
||||
&& (p.local.kind == MountKind::WebDav || p.cloud.kind == MountKind::WebDav)
|
||||
})
|
||||
.filter_map(|p: &DrivePair| p.owner_user.as_deref())
|
||||
.collect();
|
||||
|
||||
if owners.is_empty() {
|
||||
return ok(
|
||||
"davfs2 group membership",
|
||||
"no WebDAV user-context pairs with owner_user - nothing to check",
|
||||
);
|
||||
}
|
||||
|
||||
let mut missing = Vec::new();
|
||||
for owner in &owners {
|
||||
let output = Command::new("id").args(["-nG", owner]).output();
|
||||
let is_member = output
|
||||
.map(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.split_whitespace()
|
||||
.any(|g| g == "davfs2")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_member {
|
||||
missing.push(*owner);
|
||||
}
|
||||
}
|
||||
|
||||
if missing.is_empty() {
|
||||
ok(
|
||||
"davfs2 group membership",
|
||||
format!(
|
||||
"all affected users ({}) are members of the 'davfs2' group",
|
||||
owners.len()
|
||||
),
|
||||
)
|
||||
} else {
|
||||
warn(
|
||||
"davfs2 group membership",
|
||||
format!(
|
||||
"users without 'davfs2' group: {} - run 'sudo smart-mount setup fstab' (log out and back in afterwards if needed)",
|
||||
missing.join(", ")
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{CloudSide, GlobalSettings, LocalSide};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair(context: MountContext, owner_user: Option<&str>, mac: bool) -> DrivePair {
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
context,
|
||||
owner_user: owner_user.map(str::to_string),
|
||||
mount_point: "/media/smart-mount/pair-1".into(),
|
||||
local: LocalSide {
|
||||
kind: MountKind::Nfs,
|
||||
address: if mac {
|
||||
LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into())
|
||||
} else {
|
||||
LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5))
|
||||
},
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: MountKind::WebDav,
|
||||
host_or_url: "https://cloud.example.com/dav".into(),
|
||||
share: "/".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn used_mount_kinds_collects_both_sides_across_pairs() {
|
||||
let cfg = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(MountContext::System, None, false)],
|
||||
};
|
||||
let kinds = used_mount_kinds(&cfg);
|
||||
assert!(kinds.contains(&MountKind::Nfs));
|
||||
assert!(kinds.contains(&MountKind::WebDav));
|
||||
assert!(!kinds.contains(&MountKind::Smb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_mac_addressing_detects_mac_pairs() {
|
||||
let with_mac = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(MountContext::System, None, true)],
|
||||
};
|
||||
let without_mac = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(MountContext::System, None, false)],
|
||||
};
|
||||
assert!(uses_mac_addressing(&with_mac));
|
||||
assert!(!uses_mac_addressing(&without_mac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_user_context_pair_without_owner_user() {
|
||||
let cfg = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(MountContext::User, None, false)],
|
||||
};
|
||||
let results = run_checks(&cfg);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.any(|r| r.status == CheckStatus::Fail && r.label.contains("owner_user"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_flag_owner_user_when_present() {
|
||||
let cfg = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(MountContext::User, Some("dragon"), false)],
|
||||
};
|
||||
let results = run_checks(&cfg);
|
||||
assert!(!results.iter().any(|r| r.label.contains("owner_user")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_still_runs_global_checks_without_panicking() {
|
||||
let cfg = AppConfig::default();
|
||||
let results = run_checks(&cfg);
|
||||
assert!(results.iter().any(|r| r.label.contains("timeout")));
|
||||
assert!(results.iter().any(|r| r.label == "Scheduler"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Zentraler Fehlertyp für alle Bibliotheksmodule.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Sammelfehler für alle `smart-mount`-Bibliotheksmodule.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(#[from] config_ctdra::ConfyError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] turso::Error),
|
||||
|
||||
#[error("Encryption error: {0}")]
|
||||
Crypto(String),
|
||||
|
||||
#[error("Key storage error: {0}")]
|
||||
Keyring(String),
|
||||
|
||||
#[error("I/O error on '{path}': {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("Drive pair '{0}' not found")]
|
||||
PairNotFound(String),
|
||||
|
||||
#[error("Mount backend '{backend}' not available: {reason}")]
|
||||
BackendUnavailable {
|
||||
backend: &'static str,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Mount command failed ({context}): {stderr}")]
|
||||
MountFailed { context: String, stderr: String },
|
||||
|
||||
#[error("mac2ip resolution failed for MAC {mac}: {reason}")]
|
||||
Mac2Ip { mac: String, reason: String },
|
||||
|
||||
#[error("No root context: {0}")]
|
||||
RequiresRoot(&'static str),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Ergebnistyp-Alias für `smart-mount`-Bibliotheksfunktionen.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl Error {
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Einmaliges root-Setup, das unprivilegierten `User`-Kontext-Paaren erlaubt, sich selbst
|
||||
//! (unprivilegiert) zu mounten/unmounten.
|
||||
//!
|
||||
//! Kernidee: pro Paar werden **zwei** `/etc/fstab`-Zeilen geschrieben - je eine pro Seite,
|
||||
//! auf das jeweils eindeutige Backing-Verzeichnis dieser Seite (siehe
|
||||
//! [`crate::mount::target::backing_dir`]), nicht auf einen gemeinsamen Mountpoint. Damit
|
||||
//! entspricht jede Zeile exakt dem einzigen in `man 8 mount` ("Non-superuser mounts")
|
||||
//! dokumentierten Fall - genau eine fstab-Zeile pro Ziel - statt sich auf unspezifiziertes
|
||||
//! Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der sichtbare `pair.mount_point`
|
||||
//! selbst erscheint dadurch gar nicht in `/etc/fstab` - er ist ein Symlink, den smart-mount
|
||||
//! zur Laufzeit zwischen den beiden Backing-Verzeichnissen umschaltet (siehe
|
||||
//! [`crate::reconcile`]).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{AppConfig, DrivePair, GlobalSettings, MountContext, MountKind};
|
||||
use crate::db::credentials::Side;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::smb;
|
||||
use crate::mount::target::{self, backing_dir};
|
||||
|
||||
const BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
|
||||
const END_MARKER: &str = "# END smart-mount managed block";
|
||||
const FSTAB_PATH: &str = "/etc/fstab";
|
||||
|
||||
/// Führt das einmalige root-Setup für alle `User`-Kontext-Paare aus: fstab-Block
|
||||
/// regenerieren, Gruppenmitgliedschaft sicherstellen, Mountpoints anlegen.
|
||||
///
|
||||
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root - das ist der
|
||||
/// einzige Befehl in smart-mount, der das tut (alle anderen root-Aktionen verlangen
|
||||
/// explizit, bereits als root aufgerufen zu werden).
|
||||
pub fn setup() -> Result<()> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
let err = sudo_ctdra::run_as_root();
|
||||
return Err(Error::Other(format!(
|
||||
"Restart with root privileges failed: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let cfg = crate::config::pairs::load()?;
|
||||
let user_pairs: Vec<&DrivePair> = cfg
|
||||
.pairs
|
||||
.iter()
|
||||
.filter(|p| p.context == MountContext::User)
|
||||
.collect();
|
||||
|
||||
if user_pairs.is_empty() {
|
||||
logger_ctdra::info("fstab", "No user-context pairs configured - nothing to do.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_user_pairs_have_owner(&user_pairs)?;
|
||||
|
||||
for pair in &user_pairs {
|
||||
ensure_backing_dirs(pair)?;
|
||||
ensure_group_membership(pair)?;
|
||||
}
|
||||
|
||||
write_managed_block(&user_pairs, &cfg.settings)?;
|
||||
|
||||
logger_ctdra::info(
|
||||
"fstab",
|
||||
"Done. Affected users may need to log out and back in for new group memberships to \
|
||||
take effect. For MAC-based local drives in user context: set up passwordless sudo \
|
||||
access to 'nmap' if resolution does not already succeed via the ARP neighbor table \
|
||||
(see README).",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ergebnis von [`teardown`].
|
||||
pub enum FstabTeardownOutcome {
|
||||
/// Der verwaltete Block wurde gefunden und entfernt.
|
||||
Removed,
|
||||
/// Kein von smart-mount verwalteter Block vorhanden - nichts zu tun.
|
||||
NotPresent,
|
||||
}
|
||||
|
||||
/// Gegenstück zu [`setup`]: entfernt den von smart-mount verwalteten Block wieder aus
|
||||
/// `/etc/fstab` (Backup wie bei `setup` nach `/etc/fstab.smart-mount.bak`). Rührt bewusst
|
||||
/// **keine** Backing-Verzeichnisse, gemounteten Daten oder Gruppenmitgliedschaften an - nur
|
||||
/// die fstab-Zeilen selbst, da das Löschen von Verzeichnissen/Cache-Daten oder das Entfernen
|
||||
/// aus einer Gruppe ungewollte Nebenwirkungen haben könnte (die Gruppe könnte z. B. auch
|
||||
/// unabhängig von smart-mount genutzt werden).
|
||||
///
|
||||
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root (wie `setup`).
|
||||
pub fn teardown() -> Result<FstabTeardownOutcome> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
let err = sudo_ctdra::run_as_root();
|
||||
return Err(Error::Other(format!(
|
||||
"Restart with root privileges failed: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let fstab_path = PathBuf::from(FSTAB_PATH);
|
||||
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
|
||||
|
||||
if !existing.contains(BEGIN_MARKER) {
|
||||
return Ok(FstabTeardownOutcome::NotPresent);
|
||||
}
|
||||
|
||||
backup(&fstab_path, &existing)?;
|
||||
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
|
||||
std::fs::write(&fstab_path, without_block).map_err(|e| Error::io(&fstab_path, e))?;
|
||||
Ok(FstabTeardownOutcome::Removed)
|
||||
}
|
||||
|
||||
/// Ohne `owner_user` würde die fstab-Zeile ohne `uid=`/`gid=` geschrieben - bei davfs2 heißt
|
||||
/// das laut `man mount.davfs` ("uid=user"/"gid=group"): JEDES Mitglied der Gruppe 'davfs2'
|
||||
/// dürfte dieses Paar mounten, nicht nur der vorgesehene Besitzer. Lieber hart fehlschlagen,
|
||||
/// bevor eine unsichere Zeile geschrieben wird, als das still zuzulassen.
|
||||
fn validate_user_pairs_have_owner(pairs: &[&DrivePair]) -> Result<()> {
|
||||
for pair in pairs {
|
||||
if pair.owner_user.is_none() {
|
||||
return Err(Error::Other(format!(
|
||||
"Drive pair '{}' has context 'user', but no owner_user set. \
|
||||
Without owner_user, mount access cannot be restricted to a specific \
|
||||
user - please set owner_user in the configuration \
|
||||
(e.g. via 'smart-mount drive add').",
|
||||
pair.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legt beide Backing-Verzeichnisse an (nicht `pair.mount_point` selbst - das bleibt ein
|
||||
/// Symlink, siehe Moduldoku) und macht `owner_user` zum Besitzer beider.
|
||||
fn ensure_backing_dirs(pair: &DrivePair) -> Result<()> {
|
||||
for side in [Side::Local, Side::Cloud] {
|
||||
create_dir_all_owned(&backing_dir(pair, side), pair.owner_user.as_deref())?;
|
||||
}
|
||||
|
||||
// Der Elternordner des sichtbaren Mountpoints (z. B. `/run/media/<Nutzer>/smart-mount`)
|
||||
// muss dem Nutzer ebenfalls gehören - dort legt `activate_symlink` bei JEDEM `mount`/
|
||||
// `watch`-Lauf den Symlink an/ersetzt ihn, und das läuft (anders als dieses einmalige
|
||||
// Setup) unprivilegiert als der Nutzer selbst. `/run/media` ist standardmäßig `root:root
|
||||
// 0755` - ohne diesen Schritt könnte der Nutzer dort nicht einmal ein eigenes
|
||||
// Unterverzeichnis anlegen.
|
||||
if let Some(parent) = pair.mount_point.parent() {
|
||||
create_dir_all_owned(parent, pair.owner_user.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wie `std::fs::create_dir_all`, macht aber zusätzlich `owner` zum Besitzer aller dabei
|
||||
/// **neu angelegten** Verzeichnisse - nicht bereits vorhandener Elternverzeichnisse (z. B.
|
||||
/// `/run/media` selbst, das root-eigen bleiben muss). Läuft von `dir` aus rückwärts nach
|
||||
/// oben, bis der erste bereits existierende Vorfahre gefunden ist.
|
||||
fn create_dir_all_owned(dir: &std::path::Path, owner: Option<&str>) -> Result<()> {
|
||||
let Some(owner) = owner else {
|
||||
return std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e));
|
||||
};
|
||||
|
||||
let mut newly_created = Vec::new();
|
||||
let mut current = dir;
|
||||
while !current.exists() {
|
||||
newly_created.push(current.to_path_buf());
|
||||
match current.parent() {
|
||||
Some(parent) => current = parent,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
|
||||
// Von oben nach unten chownen (Eltern vor Kindern) - rein kosmetisch, jeder Aufruf ist
|
||||
// unabhängig, aber so bleibt die Reihenfolge nachvollziehbar.
|
||||
for path in newly_created.iter().rev() {
|
||||
let status = Command::new("chown")
|
||||
.arg(format!("{owner}:{owner}"))
|
||||
.arg(path)
|
||||
.status()
|
||||
.map_err(|e| Error::Other(format!("could not run chown: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(Error::Other(format!(
|
||||
"chown failed for '{}'",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_group_membership(pair: &DrivePair) -> Result<()> {
|
||||
let Some(owner) = &pair.owner_user else {
|
||||
return Ok(());
|
||||
};
|
||||
if pair.local.kind == MountKind::WebDav || pair.cloud.kind == MountKind::WebDav {
|
||||
let status = Command::new("usermod")
|
||||
.args(["-aG", "davfs2", owner])
|
||||
.status()
|
||||
.map_err(|e| Error::Other(format!("could not run usermod: {e}")))?;
|
||||
if !status.success() {
|
||||
logger_ctdra::warn(
|
||||
"fstab",
|
||||
&format!(
|
||||
"Could not add '{owner}' to group 'davfs2' - does the group exist (package 'davfs2' installed)?"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result<()> {
|
||||
let fstab_path = PathBuf::from(FSTAB_PATH);
|
||||
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
|
||||
|
||||
backup(&fstab_path, &existing)?;
|
||||
|
||||
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
|
||||
let block = render_managed_block(pairs, settings)?;
|
||||
|
||||
let new_contents = format!(
|
||||
"{}\n{}\n{}\n{}\n",
|
||||
without_block.trim_end(),
|
||||
BEGIN_MARKER,
|
||||
block.trim_end(),
|
||||
END_MARKER
|
||||
);
|
||||
std::fs::write(&fstab_path, new_contents).map_err(|e| Error::io(&fstab_path, e))
|
||||
}
|
||||
|
||||
fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> {
|
||||
let backup_path = PathBuf::from(format!("{FSTAB_PATH}.smart-mount.bak"));
|
||||
std::fs::write(&backup_path, contents).map_err(|e| Error::io(&backup_path, e))?;
|
||||
let _ = fstab_path; // nur zur Doku der Herkunft von `contents`.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result<String> {
|
||||
let mut lines = Vec::new();
|
||||
for pair in pairs {
|
||||
lines.push(fstab_line(pair, Side::Local, settings)?);
|
||||
lines.push(fstab_line(pair, Side::Cloud, settings)?);
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
|
||||
// Wiederverwendet dieselbe Options-Berechnung wie der tatsächliche Mount-Aufruf
|
||||
// (`mount::target::build_target`, inkl. `apply_owner_permissions`) - insbesondere die
|
||||
// dort injizierten uid=/gid= sind hier nicht optional: laut `man mount.davfs`
|
||||
// ("uid=user"/"gid=group") darf ein unprivilegierter Nutzer eine Zeile nur mounten, wenn
|
||||
// uid= auf ihn selbst zeigt und er Mitglied der in gid= genannten Gruppe ist. Ohne diese
|
||||
// Optionen dürfte JEDES Mitglied der Gruppe 'davfs2' JEDES konfigurierte Paar mounten,
|
||||
// nicht nur der vorgesehene Besitzer (`setup()` verweigert daher bereits vorab Paare ohne
|
||||
// `owner_user`).
|
||||
//
|
||||
// Für CIFS ist per `man mount.cifs` BESTÄTIGT, dass dieselbe Beschränkung NICHT existiert:
|
||||
// uid=/gid= betreffen dort ausschließlich die simulierte Datei-Ownership nach dem Mount,
|
||||
// nicht das Mount-*Recht* selbst - mount(8)/mount.cifs bieten keinen Mechanismus, eine
|
||||
// 'user'-fstab-Zeile auf eine bestimmte Person einzuschränken. Für CIFS-Nutzer-Kontext-
|
||||
// Paare bleibt das eine bewusst akzeptierte, strukturelle Lücke (siehe README) statt einer
|
||||
// über Mount-Optionen behebbaren - die Optionen werden trotzdem gesetzt, da korrekte
|
||||
// Ownership unabhängig davon nötig ist.
|
||||
let target = target::build_target(pair, settings, side)?;
|
||||
let kind = target::side_kind(pair, side);
|
||||
|
||||
let (fstype, mut extra_opts) = match kind {
|
||||
MountKind::WebDav => ("davfs", String::new()),
|
||||
MountKind::Smb => {
|
||||
let creds = smb::credentials_path(&pair.id, side);
|
||||
("cifs", format!(",credentials={}", creds.display()))
|
||||
}
|
||||
MountKind::Nfs => ("nfs", String::new()),
|
||||
};
|
||||
|
||||
for opt in &target.options {
|
||||
extra_opts.push_str(&format!(",{opt}"));
|
||||
}
|
||||
|
||||
// Jede Seite bekommt ihr eigenes, eindeutiges Backing-Verzeichnis als Ziel - siehe
|
||||
// Moduldoku. `pair.mount_point` selbst taucht bewusst NICHT in fstab auf.
|
||||
//
|
||||
// WICHTIG: die `user`-Option impliziert laut `man 8 mount` ("Non-superuser mounts") für
|
||||
// JEDES Dateisystem `noexec,nosuid,nodev`, sofern nicht direkt im selben Optionslisten-
|
||||
// Eintrag überschrieben. Ohne das explizite `exec` hier könnten auf einem User-Kontext-
|
||||
// Laufwerk liegende Skripte NICHT ausgeführt werden. `nosuid`/`nodev` bleiben bewusst
|
||||
// implizit (sinnvolle Absicherung, dafür gab es keine Anforderung).
|
||||
Ok(format!(
|
||||
"{source} {mount_point} {fstype} user,exec,noauto{extra_opts} 0 0",
|
||||
source = target.source,
|
||||
mount_point = target.mount_point.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Zeigt an, dass diese Konfiguration bereits ein einmaliges `setup fstab` benötigt hat.
|
||||
pub fn requires_setup(cfg: &AppConfig) -> bool {
|
||||
cfg.pairs.iter().any(|p| p.context == MountContext::User)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair() -> DrivePair {
|
||||
// Nutzt den tatsächlich ausführenden Testnutzer statt eines hartkodierten Namens,
|
||||
// da `fstab_line` jetzt `id -u`/`id -g` für `owner_user` aufruft (siehe
|
||||
// `mount::target::apply_owner_permissions`) - ein fester Name wäre auf anderen
|
||||
// Maschinen/CI nicht garantiert vorhanden.
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
context: MountContext::User,
|
||||
owner_user: Some(user.clone()),
|
||||
mount_point: "/home/dragon/smart-mount/pair-1".into(),
|
||||
local: crate::config::LocalSide {
|
||||
kind: MountKind::Smb,
|
||||
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)),
|
||||
share: "share".into(),
|
||||
username: Some("nasuser".into()),
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: crate::config::CloudSide {
|
||||
kind: MountKind::Smb,
|
||||
host_or_url: "cloud.example.com".into(),
|
||||
share: "share".into(),
|
||||
username: Some(user),
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_pairs_have_owner_rejects_missing_owner() {
|
||||
let mut pair = sample_pair();
|
||||
pair.owner_user = None;
|
||||
let err = validate_user_pairs_have_owner(&[&pair]).unwrap_err();
|
||||
assert!(err.to_string().contains("owner_user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_pairs_have_owner_accepts_pair_with_owner() {
|
||||
let pair = sample_pair();
|
||||
assert!(validate_user_pairs_have_owner(&[&pair]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_two_lines_per_pair_each_with_its_own_unique_target() {
|
||||
let pair = sample_pair();
|
||||
let block = render_managed_block(&[&pair], &GlobalSettings::default()).expect("render");
|
||||
let lines: Vec<&str> = block.lines().collect();
|
||||
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].contains("user,exec,noauto"));
|
||||
assert!(lines[0].contains("uid="));
|
||||
assert!(lines[0].contains("gid="));
|
||||
|
||||
// Der sichtbare pair.mount_point selbst darf in KEINER Zeile als Ziel auftauchen -
|
||||
// das ist der Symlink, den smart-mount zur Laufzeit umschaltet, kein fstab-Ziel.
|
||||
let visible = pair.mount_point.display().to_string();
|
||||
let target_tokens: Vec<&str> = [lines[0], lines[1]]
|
||||
.iter()
|
||||
.map(|l| l.split_whitespace().nth(1).unwrap())
|
||||
.collect();
|
||||
assert!(!target_tokens.contains(&visible.as_str()));
|
||||
|
||||
// Jede Zeile hat ein eigenes, eindeutiges Ziel (Backing-Verzeichnis) - keine zwei
|
||||
// Zeilen mit demselben Mountpoint, auf dessen Disambiguierung sich mount(8) laut
|
||||
// `man 8 mount` nicht verlassen ließe.
|
||||
let target_of = |line: &str| line.split_whitespace().nth(1).unwrap().to_string();
|
||||
assert_ne!(target_of(lines[0]), target_of(lines[1]));
|
||||
assert_eq!(
|
||||
target_of(lines[0]),
|
||||
backing_dir(&pair, Side::Local).display().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
target_of(lines[1]),
|
||||
backing_dir(&pair, Side::Cloud).display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_managed_block_removes_only_the_marked_section() {
|
||||
let contents = "/dev/sda1 / ext4 defaults 0 1\n# BEGIN smart-mount managed block\nfoo\n# END smart-mount managed block\n";
|
||||
let stripped = crate::util::strip_managed_block(contents, BEGIN_MARKER, END_MARKER);
|
||||
assert!(stripped.contains("/dev/sda1"));
|
||||
assert!(!stripped.contains("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_all_owned_creates_multi_level_path_and_chowns_new_dirs() {
|
||||
// `chown` zu einem ANDEREN Nutzer bräuchte Root - hier wird bewusst auf den eigenen
|
||||
// Nutzer "umgechownt" (funktioniert unprivilegiert, ist ein No-op auf die tatsächliche
|
||||
// Ownership, prüft aber, dass der `chown`-Aufruf pro neu angelegtem Verzeichnis
|
||||
// fehlerfrei durchläuft und die Verzeichnisstruktur korrekt entsteht).
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let target = base.path().join("a").join("b").join("c");
|
||||
|
||||
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
|
||||
|
||||
assert!(target.is_dir());
|
||||
assert!(base.path().join("a").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_all_owned_does_not_touch_already_existing_ancestors() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let target = base.path().join("existing").join("new-child");
|
||||
std::fs::create_dir_all(base.path().join("existing")).expect("pre-create ancestor");
|
||||
|
||||
// Darf nicht versuchen, `base.path()` selbst zu chownen (das existierte schon vorher) -
|
||||
// nur `existing/new-child`. Schlägt fehl, falls die Funktion stattdessen versucht,
|
||||
// einen nicht existierenden Nutzer für einen bereits vorhandenen Ordner zu setzen o. Ä.
|
||||
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
|
||||
assert!(target.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_all_owned_without_owner_just_creates_directories() {
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let target = base.path().join("x").join("y");
|
||||
create_dir_all_owned(&target, None).expect("create_dir_all_owned");
|
||||
assert!(target.is_dir());
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
//! smart-mount: bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und
|
||||
//! schaltet automatisch zwischen LAN und Cloud um.
|
||||
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod db;
|
||||
pub mod doctor;
|
||||
pub mod error;
|
||||
pub mod fstab;
|
||||
pub mod mount;
|
||||
pub mod network;
|
||||
pub mod reconcile;
|
||||
pub mod systemd;
|
||||
pub(crate) mod util;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
+23
-324
@@ -1,335 +1,34 @@
|
||||
use crate::config::{Storage, get_config, modify_config};
|
||||
use crate::filesystem::credentials::save_credentials_webdav;
|
||||
use crate::filesystem::mount::{mount, unmount};
|
||||
use crate::filesystem::mounted::{is_mounted, is_mounted_as};
|
||||
use crate::log::LogLevel;
|
||||
use crate::log::log;
|
||||
use crate::network::network_interface::{get_active_network_interface, get_interface_ip_address};
|
||||
use crate::network::utils::{
|
||||
get_ip_from_mac, get_mac_from_ip, get_network_address, is_reachable, wake_on_land,
|
||||
};
|
||||
use crate::sudo::{is_run_as_root, run_as_root};
|
||||
use std::process::exit;
|
||||
use std::thread;
|
||||
use std::thread::{JoinHandle, sleep};
|
||||
use std::time::Duration;
|
||||
mod cli;
|
||||
|
||||
mod config;
|
||||
mod filesystem;
|
||||
mod log;
|
||||
mod network;
|
||||
mod program;
|
||||
mod sudo;
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() {
|
||||
log(
|
||||
"main",
|
||||
"========== PROGRAM START ==========",
|
||||
LogLevel::Info,
|
||||
);
|
||||
use clap::Parser;
|
||||
|
||||
if !is_run_as_root() {
|
||||
log(
|
||||
"main",
|
||||
"Program is not run as root. Trying to run as root...",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
run_as_root();
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
smart_mount::config::init();
|
||||
|
||||
let network_interface: String;
|
||||
let log_level = match smart_mount::config::pairs::load() {
|
||||
Ok(cfg) => cfg.settings.log_level,
|
||||
Err(_) => "info".to_string(),
|
||||
};
|
||||
logger_ctdra::set_log_level(parse_log_level(&log_level));
|
||||
|
||||
let mut count: i32 = 0;
|
||||
loop {
|
||||
let interface_str: String = get_active_network_interface().unwrap().trim().to_string();
|
||||
|
||||
if !interface_str.is_empty() {
|
||||
network_interface = interface_str;
|
||||
break;
|
||||
} else if count >= 10 {
|
||||
log(
|
||||
"main",
|
||||
"Couldn't find active network card, exiting.",
|
||||
LogLevel::Error,
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"No active network card found, waiting 1 second.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
log(
|
||||
"main",
|
||||
&*format!("Active network interface found: {}", network_interface),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
let interface_address = get_interface_ip_address(network_interface.as_str())
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
log(
|
||||
"main",
|
||||
&*format!("Interface address: {}", interface_address),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
let network_address = get_network_address(interface_address.as_str())
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
log(
|
||||
"main",
|
||||
&*format!("Network address: {}", network_address),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
count = 0;
|
||||
loop {
|
||||
if is_reachable(network_address.as_str()) {
|
||||
break;
|
||||
} else if count >= 10 {
|
||||
log(
|
||||
"main",
|
||||
"Couldn't reach network address, exiting.",
|
||||
LogLevel::Error,
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"Network address not reachable, waiting 1 second.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
log("main", "Network address is reachable.", LogLevel::Info);
|
||||
|
||||
let handle_local: JoinHandle<()> = thread::spawn(move || mount_local(network_address));
|
||||
let handle_remote: JoinHandle<()> = thread::spawn(mount_remote);
|
||||
|
||||
handle_local.join().unwrap();
|
||||
handle_remote.join().unwrap();
|
||||
|
||||
log("main", "========== PROGRAM END ==========", LogLevel::Info);
|
||||
}
|
||||
|
||||
fn mount_local(network_address: String) {
|
||||
log(
|
||||
"main",
|
||||
"Trying to mount filesystem locally...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
let mount_point: &str = get_config().general.mount_point.as_str();
|
||||
|
||||
let mac_address: &str = get_config().local.device_mac.as_str();
|
||||
let mount_type: &str = get_config().local.mount_type.as_str();
|
||||
|
||||
let mut device_address: Option<String> = None;
|
||||
|
||||
if get_config().storage.is_some() {
|
||||
device_address = Some(get_config().storage.clone().unwrap().device_ip);
|
||||
|
||||
if is_reachable(&device_address.clone().unwrap()) {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Searching mac for device address {}.",
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Info,
|
||||
);
|
||||
let mac_of_ip =
|
||||
get_mac_from_ip(device_address.clone().unwrap().as_str()).unwrap_or("".to_string());
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Found mac {} for device address {}.",
|
||||
mac_of_ip,
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
if mac_of_ip == mac_address {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Found device mac {} on saved ip {}.",
|
||||
mac_address,
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Info,
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Device mac {} is not the same as {} of ip {}.",
|
||||
mac_address,
|
||||
mac_of_ip,
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Warn,
|
||||
);
|
||||
device_address = None;
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Device address {} is not reachable.",
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Warn,
|
||||
);
|
||||
device_address = None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut count: i32 = 0;
|
||||
|
||||
if device_address.is_none() {
|
||||
loop {
|
||||
device_address = get_ip_from_mac(mac_address, network_address.as_str());
|
||||
if device_address.is_some() || count >= 10 {
|
||||
modify_config(|config| {
|
||||
let storage = config.storage.get_or_insert(Storage::default());
|
||||
storage.device_ip = device_address.clone().unwrap();
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"Couldn't find MAC adress in local network, sending awake call...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
wake_on_land(mac_address);
|
||||
|
||||
log("main", "Waiting 30 seconds...", LogLevel::Info);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
|
||||
if device_address.is_none() {
|
||||
log(
|
||||
"main",
|
||||
"Couldn't find device address for MAC address.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
|
||||
if is_mounted_as(mount_point, mount_type) {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is mounted locally. Unmounting...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
unmount(mount_point);
|
||||
}
|
||||
|
||||
mount_remote();
|
||||
} else {
|
||||
let dev_ip: String = device_address.unwrap();
|
||||
log(
|
||||
"main",
|
||||
"Found MAC address in local network.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
if !is_mounted_as(mount_point, mount_type) {
|
||||
if is_mounted(mount_point) {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is mounted. Unmounting...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
unmount(mount_point);
|
||||
}
|
||||
|
||||
log("main", "Mounting local filesystem...", LogLevel::Info);
|
||||
mount(
|
||||
mount_point,
|
||||
&*format!("{}:{}", dev_ip, get_config().local.mount_path),
|
||||
mount_type,
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is already mounted locally. Doing nothing.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
let cli = cli::Cli::parse();
|
||||
match cli::dispatch(cli).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_remote() {
|
||||
let mount_point: &str = get_config().general.mount_point.as_str();
|
||||
let mount_type: &str = &get_config().remote.mount_type.as_str();
|
||||
|
||||
let mut could_reach: bool;
|
||||
let mut count: i32 = 0;
|
||||
|
||||
loop {
|
||||
could_reach = is_reachable("https://ping.creative-dragonslayer.de");
|
||||
|
||||
if could_reach || count >= 10 {
|
||||
break;
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"Couldn't reach remote server, waiting 1 second.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
if could_reach {
|
||||
log("main", "Remote server reachable.", LogLevel::Info);
|
||||
|
||||
if mount_type == "davfs" || mount_type == "webdav" {
|
||||
save_credentials_webdav();
|
||||
}
|
||||
|
||||
if is_mounted_as(mount_point, get_config().local.mount_type.as_str())
|
||||
|| is_mounted_as(mount_point, mount_type)
|
||||
{
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is already mounted. Doing nothing.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
} else {
|
||||
if is_mounted(mount_point) {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is already mounted. Unmounting...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
unmount(mount_point);
|
||||
}
|
||||
|
||||
log("main", "Mounting remote filesystem...", LogLevel::Info);
|
||||
mount(mount_point, &*get_config().remote.mount_path, mount_type);
|
||||
}
|
||||
} else {
|
||||
log("main", "Remote server not reachable.", LogLevel::Warn);
|
||||
fn parse_log_level(level: &str) -> logger_ctdra::LogLevel {
|
||||
match level.to_lowercase().as_str() {
|
||||
"error" => logger_ctdra::LogLevel::Error,
|
||||
"warn" => logger_ctdra::LogLevel::Warn,
|
||||
"debug" => logger_ctdra::LogLevel::Debug,
|
||||
_ => logger_ctdra::LogLevel::Info,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Pro-Paar-Mutex-Registry, damit ein manueller `mount --name X` nicht mit einem
|
||||
//! gleichzeitig laufenden `watch` für dasselbe Paar kollidiert.
|
||||
//!
|
||||
//! Ersetzt den globalen `RwLock`+`Mutex` aus dem alten `src/filesystem/mount.rs` (v0.2.0):
|
||||
//! dort war die Sperre prozessweit global, hier ist sie pro Laufwerkspaar - mehrere Paare
|
||||
//! können also parallel gemountet werden, ohne sich gegenseitig zu blockieren.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
|
||||
|
||||
type Registry = Mutex<HashMap<String, Arc<AsyncMutex<()>>>>;
|
||||
|
||||
fn registry() -> &'static Registry {
|
||||
static REGISTRY: OnceLock<Registry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Sperrt ein Laufwerkspaar für die Dauer des zurückgegebenen Guards. `tokio::sync::Mutex`s
|
||||
/// `lock_owned()` erlaubt einen Guard, der seine eigene `Arc`-Referenz hält - keine
|
||||
/// selbstreferenzielle Struktur/`unsafe` nötig.
|
||||
pub async fn acquire(pair_id: &str) -> OwnedMutexGuard<()> {
|
||||
let mutex = {
|
||||
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
|
||||
reg.entry(pair_id.to_string())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
|
||||
.clone()
|
||||
};
|
||||
mutex.lock_owned().await
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Mount-Backends für WebDAV/SMB/NFS mit dynamischem Dispatch.
|
||||
//!
|
||||
//! "Dynamisch nachladen" bedeutet hier: Trait-Object-Dispatch je nach [`MountKind`], und
|
||||
//! jedes Backend prüft sein benötigtes System-Binary (`mount.davfs`/`mount.cifs`/`mount.nfs`)
|
||||
//! erst, wenn es tatsächlich benutzt wird ([`MountBackend::check_available`]) - ein reiner
|
||||
//! WebDAV-Nutzer wird also nicht gezwungen, `cifs-utils`/`nfs-common` zu installieren.
|
||||
|
||||
pub mod lock;
|
||||
pub mod nfs;
|
||||
pub mod smb;
|
||||
pub mod state;
|
||||
pub mod target;
|
||||
pub mod webdav;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{DrivePair, GlobalSettings, MountKind};
|
||||
use crate::db::credentials::{Credential, Side};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Wie ein Mount-Aufruf ausgeführt wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MountInvocation {
|
||||
/// Root, volle `-o`-Optionen: `mount -t <type> <source> <target> -o <opts>`.
|
||||
Direct,
|
||||
/// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount <target>` (nur das
|
||||
/// Ziel, genau der in `man 8 mount` dokumentierte Fall `mount /cd`). Da jede Seite ihr
|
||||
/// eigenes, eindeutiges Backing-Verzeichnis hat (siehe [`target::backing_dir`]), gibt es
|
||||
/// dabei nie mehr als eine passende fstab-Zeile. Voraussetzung: `smart-mount setup fstab`
|
||||
/// wurde für dieses Paar bereits ausgeführt.
|
||||
ViaFstab,
|
||||
}
|
||||
|
||||
/// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein-
|
||||
/// bzw. auszuhängen.
|
||||
pub struct MountTarget {
|
||||
pub pair_id: String,
|
||||
/// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile
|
||||
/// Dateinamen für Credentials-/Secrets-Dateien.
|
||||
pub side: Side,
|
||||
pub mount_point: PathBuf,
|
||||
/// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`.
|
||||
pub source: String,
|
||||
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`), nur bei
|
||||
/// `MountInvocation::Direct` verwendet - mit Kommas verbindbar für `mount -o`.
|
||||
pub options: Vec<String>,
|
||||
pub invocation: MountInvocation,
|
||||
/// Für `MountContext::User`-Paare: der Linux-Benutzername, dem Zugangsdaten-/Secrets-
|
||||
/// Dateien gehören sollen.
|
||||
pub owner_user: Option<String>,
|
||||
}
|
||||
|
||||
/// Gemeinsame Schnittstelle für WebDAV/SMB/NFS-Backends.
|
||||
pub trait MountBackend: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Prüft, ob das für dieses Backend nötige System-Binary vorhanden ist. Liefert bei
|
||||
/// Fehlen einen Fehler mit dem Namen des nachzuinstallierenden Pakets.
|
||||
fn check_available(&self) -> Result<()>;
|
||||
|
||||
/// Schreibt/aktualisiert alles, was der eigentliche `mount`-Aufruf voraussetzt
|
||||
/// (Credentials-/Secrets-Dateien, Config-Anpassungen wie davfs2s `gui_optimize`).
|
||||
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()>;
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()>;
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Wählt das passende Backend für einen [`MountKind`] (Trait-Object-Dispatch).
|
||||
pub fn backend_for(kind: MountKind) -> Box<dyn MountBackend> {
|
||||
match kind {
|
||||
MountKind::WebDav => Box::new(webdav::WebDavBackend),
|
||||
MountKind::Smb => Box::new(smb::SmbBackend),
|
||||
MountKind::Nfs => Box::new(nfs::NfsBackend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout in Sekunden für `mount`/`umount`-Subprozesse (siehe [`run_tolerating_already_done`]).
|
||||
/// Ein nicht mehr erreichbarer Server darf `smart-mount watch` niemals unbegrenzt blockieren -
|
||||
/// z. B. ist ein `umount` auf einem davfs2-Mount laut davfs2-eigener FAQ absichtlich so lange
|
||||
/// blockierend, bis alle zwischengespeicherten Daten geschrieben sind, was bei einem dauerhaft
|
||||
/// unerreichbaren Server sonst nie zurückkehren würde.
|
||||
pub(crate) const MOUNT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Führt einen `mount`/`umount`-Subprozess mit einem externen Timeout aus (über das
|
||||
/// coreutils-Tool `timeout`, auf jedem Linux-System vorhanden) und toleriert "bereits
|
||||
/// eingebunden"/"busy"/"nicht eingebunden" als No-op statt als Fehler (portiert aus dem alten
|
||||
/// `src/filesystem/mount.rs`, v0.2.0).
|
||||
///
|
||||
/// `tolerate_timeout`: bei `umount`-Aufrufen (`true`) wird ein durch den Timeout abgebrochener
|
||||
/// Versuch nur geloggt und als Erfolg gewertet - der nächste `watch`-Durchlauf versucht es
|
||||
/// erneut (idempotent, `umount` toleriert bereits "nicht eingebunden"). Bei `mount`-Aufrufen
|
||||
/// (`false`) ist ein Timeout ein echter Fehlschlag, da dabei nichts erfolgreich eingebunden
|
||||
/// wurde.
|
||||
pub(crate) fn run_tolerating_already_done(
|
||||
cmd: std::process::Command,
|
||||
context: &str,
|
||||
tolerate_timeout: bool,
|
||||
) -> Result<()> {
|
||||
run_tolerating_already_done_with_timeout(cmd, context, tolerate_timeout, MOUNT_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
fn run_tolerating_already_done_with_timeout(
|
||||
cmd: std::process::Command,
|
||||
context: &str,
|
||||
tolerate_timeout: bool,
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
let output =
|
||||
run_with_timeout(cmd, timeout_secs).map_err(|e| crate::error::Error::MountFailed {
|
||||
context: context.to_string(),
|
||||
stderr: e.to_string(),
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// GNU coreutils `timeout` beendet sich mit Exit-Code 124, wenn es den Kindprozess wegen
|
||||
// Zeitüberschreitung abbrechen musste (dokumentiertes Verhalten von `timeout(1)`).
|
||||
if tolerate_timeout && output.status.code() == Some(124) {
|
||||
logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!(
|
||||
"{context}: aborted after {timeout_secs}s (server likely unreachable) - will be retried on the next run"
|
||||
),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
if stderr.contains("already mounted")
|
||||
|| stderr.contains("busy")
|
||||
|| stderr.contains("not mounted")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(crate::error::Error::MountFailed {
|
||||
context: context.to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Führt `cmd` über das coreutils-Tool `timeout` aus, damit ein hängender `mount`/`umount`
|
||||
/// den aufrufenden `smart-mount`-Prozess nie unbegrenzt blockiert.
|
||||
fn run_with_timeout(
|
||||
cmd: std::process::Command,
|
||||
timeout_secs: u64,
|
||||
) -> std::io::Result<std::process::Output> {
|
||||
let program = cmd.get_program().to_os_string();
|
||||
let args: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
|
||||
std::process::Command::new("timeout")
|
||||
.arg(timeout_secs.to_string())
|
||||
.arg(program)
|
||||
.args(args)
|
||||
.output()
|
||||
}
|
||||
|
||||
/// Prüft per `which`, ob ein Binary im `PATH` auffindbar ist.
|
||||
pub(crate) fn binary_available(binary: &str) -> bool {
|
||||
std::process::Command::new("which")
|
||||
.arg(binary)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Entfernt (best-effort) die für `pair` auf beiden Seiten hinterlegten Klartext-
|
||||
/// Zugangsdaten, die `mount -t davfs`/`mount -t cifs` benötigen (NICHT die verschlüsselten
|
||||
/// DB-Zeilen - die werden separat über `CredentialStore::delete` entfernt). Für
|
||||
/// `smart-mount drive remove`, damit kein Passwort für ein gelöschtes Paar auf der Platte
|
||||
/// zurückbleibt. Fehler werden nur geloggt, nie propagiert: eine nicht perfekt aufräumbare
|
||||
/// Restdatei darf das Entfernen des Paars nicht blockieren.
|
||||
pub fn cleanup_credentials(pair: &DrivePair, settings: &GlobalSettings) {
|
||||
cleanup_side_credentials(pair, settings, Side::Local, pair.local.kind);
|
||||
cleanup_side_credentials(pair, settings, Side::Cloud, pair.cloud.kind);
|
||||
}
|
||||
|
||||
fn cleanup_side_credentials(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
side: Side,
|
||||
kind: MountKind,
|
||||
) {
|
||||
match kind {
|
||||
MountKind::Smb => {
|
||||
let path = smb::credentials_path(&pair.id, side);
|
||||
if path.exists()
|
||||
&& let Err(e) = std::fs::remove_file(&path)
|
||||
{
|
||||
logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!(
|
||||
"Could not delete credentials file '{}': {e}",
|
||||
path.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
MountKind::WebDav => {
|
||||
let source = match side {
|
||||
Side::Local => target::local_source(&pair.local, settings),
|
||||
Side::Cloud => target::cloud_source(&pair.cloud),
|
||||
};
|
||||
let path = webdav::davfs2_secrets_path();
|
||||
if let Err(e) = webdav::remove_secrets_entry(&path, &source) {
|
||||
logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!("Could not remove secrets entry for '{source}': {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
MountKind::Nfs => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn run_with_timeout_kills_a_hanging_command_and_reports_exit_124() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("5");
|
||||
let output = run_with_timeout(cmd, 1).expect("timeout wrapper itself should run");
|
||||
// GNU coreutils `timeout` exits 124 when it had to kill the child - this is the
|
||||
// signal `run_tolerating_already_done_with_timeout` checks for below.
|
||||
assert_eq!(output.status.code(), Some(124));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_with_timeout_returns_promptly_for_a_command_that_finishes_in_time() {
|
||||
let cmd = Command::new("true");
|
||||
let started = std::time::Instant::now();
|
||||
let output = run_with_timeout(cmd, 10).expect("run");
|
||||
assert!(output.status.success());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_is_tolerated_for_unmount_and_returns_ok() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("5");
|
||||
let result = run_tolerating_already_done_with_timeout(cmd, "test umount", true, 1);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_is_a_hard_error_for_mount() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("5");
|
||||
let result = run_tolerating_already_done_with_timeout(cmd, "test mount", false, 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! NFS-Backend (`mount.nfs`). In der Regel keine Zugangsdaten nötig - Autorisierung erfolgt
|
||||
//! serverseitig über Export-ACLs (`sec=sys`), optional `sec=krb5*` über `extra_options`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use crate::db::credentials::Credential;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{
|
||||
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
|
||||
};
|
||||
|
||||
pub struct NfsBackend;
|
||||
|
||||
impl MountBackend for NfsBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"nfs"
|
||||
}
|
||||
|
||||
fn check_available(&self) -> Result<()> {
|
||||
if binary_available("mount.nfs") || binary_available("mount.nfs4") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BackendUnavailable {
|
||||
backend: "nfs",
|
||||
reason: "'mount.nfs'/'mount.nfs4' not found - install package 'nfs-common' (Debian/Ubuntu) or 'nfs-utils' (Fedora/Arch)".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, _target: &MountTarget, _cred: Option<&Credential>) -> Result<()> {
|
||||
// Normalerweise kein Vorbereitungsschritt nötig (kein Credentials-File).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
match target.invocation {
|
||||
MountInvocation::Direct => {
|
||||
cmd.arg("-t")
|
||||
.arg("nfs")
|
||||
.arg(&target.source)
|
||||
.arg(&target.mount_point);
|
||||
if !target.options.is_empty() {
|
||||
cmd.arg("-o").arg(target.options.join(","));
|
||||
}
|
||||
}
|
||||
MountInvocation::ViaFstab => {
|
||||
cmd.arg(&target.mount_point);
|
||||
}
|
||||
}
|
||||
run_tolerating_already_done(cmd, "nfs mount", false)
|
||||
}
|
||||
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("umount");
|
||||
cmd.arg(&target.mount_point);
|
||||
run_tolerating_already_done(cmd, "nfs umount", true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! SMB/CIFS-Backend (`mount.cifs`).
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::db::credentials::Credential;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{
|
||||
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
|
||||
};
|
||||
|
||||
pub struct SmbBackend;
|
||||
|
||||
impl MountBackend for SmbBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"cifs"
|
||||
}
|
||||
|
||||
fn check_available(&self) -> Result<()> {
|
||||
if binary_available("mount.cifs") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BackendUnavailable {
|
||||
backend: "cifs",
|
||||
reason: "'mount.cifs' not found - install package 'cifs-utils'".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> {
|
||||
if let Some(cred) = cred {
|
||||
write_credentials_file(&credentials_path(&target.pair_id, target.side), cred)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
match target.invocation {
|
||||
MountInvocation::Direct => {
|
||||
cmd.arg("-t")
|
||||
.arg("cifs")
|
||||
.arg(&target.source)
|
||||
.arg(&target.mount_point);
|
||||
let mut opts = target.options.clone();
|
||||
opts.push(format!(
|
||||
"credentials={}",
|
||||
credentials_path(&target.pair_id, target.side).display()
|
||||
));
|
||||
cmd.arg("-o").arg(opts.join(","));
|
||||
}
|
||||
MountInvocation::ViaFstab => {
|
||||
cmd.arg(&target.mount_point);
|
||||
}
|
||||
}
|
||||
run_tolerating_already_done(cmd, "cifs mount", false)
|
||||
}
|
||||
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("umount");
|
||||
cmd.arg(&target.mount_point);
|
||||
run_tolerating_already_done(cmd, "cifs umount", true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stabiler Pfad (nicht ein Tempfile!), da bei `MountInvocation::ViaFstab` die fstab-Zeile
|
||||
/// (von `setup fstab` einmalig geschrieben) exakt auf diesen `credentials=`-Pfad verweist.
|
||||
pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf {
|
||||
let base = config_ctdra::get_config_path()
|
||||
.parent()
|
||||
.map(|d| d.join("creds"))
|
||||
.unwrap_or_else(|| PathBuf::from("creds"));
|
||||
base.join(format!("{pair_id}-{}.cred", side.as_str()))
|
||||
}
|
||||
|
||||
fn write_credentials_file(path: &PathBuf, cred: &Credential) -> Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
|
||||
let mut contents = String::new();
|
||||
if let Some(username) = &cred.username {
|
||||
contents.push_str(&format!("username={username}\n"));
|
||||
}
|
||||
contents.push_str(&format!("password={}\n", cred.password));
|
||||
if let Some(domain) = &cred.domain {
|
||||
contents.push_str(&format!("domain={domain}\n"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut opts = OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let mut opts = OpenOptions::new();
|
||||
|
||||
let mut file = opts
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
file.write_all(contents.as_bytes())
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::credentials::Side;
|
||||
|
||||
#[test]
|
||||
fn writes_expected_credentials_format() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("test.cred");
|
||||
let cred = Credential {
|
||||
username: Some("nasuser".to_string()),
|
||||
domain: Some("WORKGROUP".to_string()),
|
||||
password: "s3cret".to_string(),
|
||||
};
|
||||
|
||||
write_credentials_file(&path, &cred).expect("write");
|
||||
let contents = std::fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert!(contents.contains("username=nasuser"));
|
||||
assert!(contents.contains("password=s3cret"));
|
||||
assert!(contents.contains("domain=WORKGROUP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_path_differs_per_side() {
|
||||
let local = credentials_path("pair-1", Side::Local);
|
||||
let cloud = credentials_path("pair-1", Side::Cloud);
|
||||
assert_ne!(local, cloud);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Ermittelt den aktuellen Mount-Zustand eines Mountpoints durch Parsen von
|
||||
//! `/proc/self/mountinfo` - keine Zusatzabhängigkeit auf `findmnt`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Ob `mount_point` aktuell eingebunden ist.
|
||||
pub fn is_mounted(mount_point: &Path) -> Result<bool> {
|
||||
Ok(current_source(mount_point)?.is_some())
|
||||
}
|
||||
|
||||
/// Die aktuell an `mount_point` eingebundene Quelle (die Spalte "source" aus mountinfo),
|
||||
/// oder `None`, falls dort nichts eingebunden ist.
|
||||
pub fn current_source(mount_point: &Path) -> Result<Option<String>> {
|
||||
let contents = std::fs::read_to_string("/proc/self/mountinfo")
|
||||
.map_err(|e| Error::io("/proc/self/mountinfo", e))?;
|
||||
Ok(parse_mountinfo_source(&contents, mount_point))
|
||||
}
|
||||
|
||||
/// Reine, testbare Parse-Funktion: `mountinfo`-Zeilenformat ist
|
||||
/// `... <mount_point> <mount_options> <optional fields> - <fs_type> <source> <super_options>`.
|
||||
/// Bei mehreren Treffern (verschachtelte Mounts) zählt der letzte (= zuletzt gemountete,
|
||||
/// aktuell sichtbare) Eintrag.
|
||||
fn parse_mountinfo_source(mountinfo: &str, mount_point: &Path) -> Option<String> {
|
||||
let target = mount_point.to_string_lossy();
|
||||
let mut result = None;
|
||||
|
||||
for line in mountinfo.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
// Feld 4 (Index 4) ist der Mountpoint; danach folgen optionale Felder bis zum
|
||||
// Trenner "-", danach fs_type (Index+1) und source (Index+2).
|
||||
if fields.len() < 5 || fields[4] != target {
|
||||
continue;
|
||||
}
|
||||
let Some(dash_pos) = fields.iter().position(|&f| f == "-") else {
|
||||
continue;
|
||||
};
|
||||
if let Some(source) = fields.get(dash_pos + 2) {
|
||||
result = Some(source.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn finds_source_for_matching_mount_point() {
|
||||
let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw\n\
|
||||
43 36 0:26 / /home/dragon/smart-mount/pair-1 rw,relatime shared:2 - cifs //server/share rw,uid=1000";
|
||||
let source =
|
||||
parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1"));
|
||||
assert_eq!(source.as_deref(), Some("//server/share"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_unmounted_path() {
|
||||
let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw";
|
||||
let source =
|
||||
parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1"));
|
||||
assert_eq!(source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_matching_entry_wins_for_stacked_mounts() {
|
||||
let mountinfo = "36 35 98:0 / /mnt/x rw - nfs server:/export rw\n\
|
||||
37 36 0:26 / /mnt/x rw - davfs https://cloud/dav rw";
|
||||
let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/x"));
|
||||
assert_eq!(source.as_deref(), Some("https://cloud/dav"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Baut [`MountTarget`]s für eine Seite eines Paars und verwaltet den symlink-basierten
|
||||
//! "welche Seite ist aktiv"-Zustand.
|
||||
//!
|
||||
//! **Warum ein Symlink statt zwei fstab-Zeilen auf denselben Mountpoint:** `mount(8)`s
|
||||
//! Berechtigungsprüfung für unprivilegierte `user`-Mounts ist nur für den Fall EINER
|
||||
//! passenden fstab-Zeile dokumentiert (`man 8 mount`, Abschnitt "Non-superuser mounts",
|
||||
//! Beispiel `mount /cd`). Der Fall zweier `user,noauto`-Zeilen mit demselben Ziel, aber
|
||||
//! unterschiedlicher Quelle, ist nirgends spezifiziert - und `mount --fstab <alternative>`
|
||||
//! verlangt selbst Root, sodass sich das Verhalten nicht einmal gefahrlos in einer Sandbox
|
||||
//! verifizieren ließ. Um uns nicht auf unspezifiziertes Verhalten zu verlassen, bekommt jede
|
||||
//! Seite stattdessen ein eigenes, eindeutiges Backing-Verzeichnis mit genau einer fstab-Zeile
|
||||
//! (der dokumentierte, eindeutige Fall). Der konfigurierte `pair.mount_point` ist kein
|
||||
//! Mountpoint mehr, sondern ein Symlink, den smart-mount selbst atomar zwischen beiden
|
||||
//! Backing-Verzeichnissen umschaltet ([`activate_symlink`]). Zu jedem Zeitpunkt ist höchstens
|
||||
//! kurz während eines Umschaltens (siehe [`crate::reconcile`]) mehr als ein Backing-Verzeichnis
|
||||
//! gemountet - im Ruhezustand immer nur eines, wie ursprünglich vorgesehen.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountContext, MountKind};
|
||||
use crate::db::credentials::Side;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{self, MountInvocation, MountTarget};
|
||||
use crate::network::address;
|
||||
|
||||
/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird
|
||||
/// tatsächlich `mount(8)` aufgerufen. Liegt als verstecktes Verzeichnis neben `pair.mount_point`.
|
||||
pub fn backing_dir(pair: &DrivePair, side: Side) -> PathBuf {
|
||||
let parent = pair.mount_point.parent().unwrap_or_else(|| Path::new("."));
|
||||
parent.join(format!(".{}-{}", pair.id, side.as_str()))
|
||||
}
|
||||
|
||||
/// Welches [`MountKind`] eine Seite eines Paars hat.
|
||||
pub fn side_kind(pair: &DrivePair, side: Side) -> MountKind {
|
||||
match side {
|
||||
Side::Local => pair.local.kind,
|
||||
Side::Cloud => pair.cloud.kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut ein [`MountTarget`] für eine Seite eines Paars, inkl. Auflösung der lokalen
|
||||
/// MAC-Adresse zu einer IP (siehe [`address::resolve_ip`]). `target.mount_point` ist das
|
||||
/// Backing-Verzeichnis (siehe [`backing_dir`]), nicht der sichtbare `pair.mount_point`.
|
||||
pub fn build_target(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
side: Side,
|
||||
) -> Result<MountTarget> {
|
||||
let invocation = match pair.context {
|
||||
MountContext::System => MountInvocation::Direct,
|
||||
MountContext::User => MountInvocation::ViaFstab,
|
||||
};
|
||||
let kind = side_kind(pair, side);
|
||||
|
||||
let (source, mut options) = match side {
|
||||
Side::Local => (
|
||||
local_source(&pair.local, settings),
|
||||
parse_options(&pair.local.extra_options),
|
||||
),
|
||||
Side::Cloud => (
|
||||
cloud_source(&pair.cloud),
|
||||
parse_options(&pair.cloud.extra_options),
|
||||
),
|
||||
};
|
||||
apply_owner_permissions(kind, pair.owner_user.as_deref(), &mut options)?;
|
||||
apply_nfs_resilience_defaults(kind, &mut options);
|
||||
|
||||
Ok(MountTarget {
|
||||
pair_id: pair.id.clone(),
|
||||
side,
|
||||
mount_point: backing_dir(pair, side),
|
||||
source,
|
||||
options,
|
||||
invocation,
|
||||
owner_user: pair.owner_user.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Setzt `uid`/`gid`/`file_mode`/`dir_mode` für Protokolle ohne native Unix-Rechte (CIFS,
|
||||
/// WebDAV), damit `pair.owner_user` vollen Zugriff auf den Mount hat - inklusive
|
||||
/// Ausführrechten, damit dort liegende Skripte laufen können (`file_mode`/`dir_mode` steuern
|
||||
/// bei diesen Protokollen den simulierten `stat()`-Modus jeder Datei/jedes Verzeichnisses
|
||||
/// einheitlich; `0700` gibt ausschließlich dem Owner volle Rechte). Bereits in
|
||||
/// `extra_options` explizit gesetzte Werte werden respektiert und nicht überschrieben.
|
||||
///
|
||||
/// **NFS ist bewusst ausgenommen:** dort gibt es keine clientseitige `uid=`/`gid=`-Option -
|
||||
/// welcher lokale Nutzer Zugriff hat, bestimmt der NFS-Server über die tatsächlichen
|
||||
/// Datei-Eigentümer/-Rechte des Exports (siehe README, Abschnitt "Voraussetzungen").
|
||||
fn apply_owner_permissions(
|
||||
kind: MountKind,
|
||||
owner_user: Option<&str>,
|
||||
options: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
if !matches!(kind, MountKind::Smb | MountKind::WebDav) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(owner) = owner_user else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !has_option(options, "uid") || !has_option(options, "gid") {
|
||||
let (uid, gid) = resolve_uid_gid(owner)?;
|
||||
if !has_option(options, "uid") {
|
||||
options.push(format!("uid={uid}"));
|
||||
}
|
||||
if !has_option(options, "gid") {
|
||||
options.push(format!("gid={gid}"));
|
||||
}
|
||||
}
|
||||
if !has_option(options, "file_mode") {
|
||||
options.push("file_mode=0700".to_string());
|
||||
}
|
||||
if !has_option(options, "dir_mode") {
|
||||
options.push("dir_mode=0700".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt `soft` als NFS-Standard, sofern der Nutzer nicht bereits selbst `hard`/`soft`/
|
||||
/// `softerr` gesetzt hat. `hard` (der Standard von `mount.nfs`, wenn nichts angegeben ist)
|
||||
/// lässt NFS-Anfragen unbegrenzt oft erneut versuchen, wenn der Server nicht antwortet -
|
||||
/// genau das Einfrierverhalten (auch bei `umount`), das automatisches Umschalten unmöglich
|
||||
/// machen würde. Siehe `man 5 nfs`, Abschnitt "soft / softerr / hard": dort wird ein
|
||||
/// dauerhaft nicht erreichbarer Server als der Anwendungsfall genannt, für den `soft`
|
||||
/// gedacht ist.
|
||||
fn apply_nfs_resilience_defaults(kind: MountKind, options: &mut Vec<String>) {
|
||||
if kind != MountKind::Nfs {
|
||||
return;
|
||||
}
|
||||
if !has_option(options, "hard")
|
||||
&& !has_option(options, "soft")
|
||||
&& !has_option(options, "softerr")
|
||||
{
|
||||
options.push("soft".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Prüft, ob `options` bereits einen Eintrag für `key` enthält - entweder als `key=wert`
|
||||
/// oder als bloßes Flag `key` (z. B. `soft`, `exec`).
|
||||
fn has_option(options: &[String], key: &str) -> bool {
|
||||
options
|
||||
.iter()
|
||||
.any(|o| o == key || o.starts_with(&format!("{key}=")))
|
||||
}
|
||||
|
||||
fn resolve_uid_gid(username: &str) -> Result<(u32, u32)> {
|
||||
Ok((run_id(username, "-u")?, run_id(username, "-g")?))
|
||||
}
|
||||
|
||||
fn run_id(username: &str, flag: &str) -> Result<u32> {
|
||||
let output = std::process::Command::new("id")
|
||||
.arg(flag)
|
||||
.arg(username)
|
||||
.output()
|
||||
.map_err(|e| Error::Other(format!("could not run 'id': {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Other(format!(
|
||||
"user '{username}' not found ('id {flag} {username}' failed): {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|e| {
|
||||
Error::Other(format!(
|
||||
"unexpected output from 'id {flag} {username}': {e}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> String {
|
||||
let ip = address::resolve_ip(&local.address, settings)
|
||||
.map(|ip| ip.to_string())
|
||||
.unwrap_or_else(|_| "unresolved".to_string());
|
||||
format_source(local.kind, &ip, &local.share)
|
||||
}
|
||||
|
||||
pub fn cloud_source(cloud: &CloudSide) -> String {
|
||||
match cloud.kind {
|
||||
MountKind::WebDav => cloud.host_or_url.clone(),
|
||||
MountKind::Smb | MountKind::Nfs => {
|
||||
format_source(cloud.kind, &cloud.host_or_url, &cloud.share)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_source(kind: MountKind, host: &str, share: &str) -> String {
|
||||
let share = if share.starts_with('/') {
|
||||
share.to_string()
|
||||
} else {
|
||||
format!("/{share}")
|
||||
};
|
||||
match kind {
|
||||
MountKind::WebDav => format!("http://{host}{share}"),
|
||||
MountKind::Smb => format!("//{host}{share}"),
|
||||
MountKind::Nfs => format!("{host}:{share}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `extra_options` sind bereits einzelne Tokens (kein komma-getrennter String) - einfach
|
||||
/// übernehmen. Frühere Versionen filterten hier auf `key=value`-Paare, wodurch bloße Flags
|
||||
/// wie `soft`/`exec`/`ro` in `extra_options` still verworfen wurden - siehe Testfall unten.
|
||||
fn parse_options(extra: &[String]) -> Vec<String> {
|
||||
extra.to_vec()
|
||||
}
|
||||
|
||||
/// Welche Seite aktuell aktiv ist: `pair.mount_point` muss auf das Backing-Verzeichnis dieser
|
||||
/// Seite zeigen UND dieses Verzeichnis muss tatsächlich gemountet sein (Schutz gegen einen
|
||||
/// veralteten Symlink, dessen Backing-Verzeichnis extern ausgehängt wurde).
|
||||
pub fn active_side(pair: &DrivePair) -> Option<Side> {
|
||||
let link_target = std::fs::read_link(&pair.mount_point).ok()?;
|
||||
let side = if link_target == backing_dir(pair, Side::Local) {
|
||||
Side::Local
|
||||
} else if link_target == backing_dir(pair, Side::Cloud) {
|
||||
Side::Cloud
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match mount::state::is_mounted(&backing_dir(pair, side)) {
|
||||
Ok(true) => Some(side),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt `pair.mount_point` atomar als Symlink auf das Backing-Verzeichnis von `side`.
|
||||
///
|
||||
/// Atomar über `symlink` auf einen Temp-Pfad + `rename()` (POSIX-garantiert atomar auf
|
||||
/// demselben Dateisystem) - es gibt also kein Zeitfenster, in dem der Pfad fehlt oder auf ein
|
||||
/// veraltetes Ziel zeigt. Schlägt kontrolliert fehl (statt zu überschreiben), falls an
|
||||
/// `pair.mount_point` bereits ein echtes Verzeichnis (kein Symlink) existiert.
|
||||
pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> {
|
||||
let link_path = &pair.mount_point;
|
||||
|
||||
if let Ok(meta) = std::fs::symlink_metadata(link_path)
|
||||
&& !meta.file_type().is_symlink()
|
||||
{
|
||||
return Err(Error::Other(format!(
|
||||
"'{}' already exists as a real directory (not as a symlink managed by smart-mount) - \
|
||||
please remove/rename it manually before activating this pair.",
|
||||
link_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(parent) = link_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
|
||||
}
|
||||
|
||||
let target = backing_dir(pair, side);
|
||||
let tmp_name = format!(
|
||||
".{}.smart-mount-tmp",
|
||||
link_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let tmp_path = link_path.with_file_name(tmp_name);
|
||||
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
std::os::unix::fs::symlink(&target, &tmp_path).map_err(|e| Error::io(&tmp_path, e))?;
|
||||
std::fs::rename(&tmp_path, link_path).map_err(|e| Error::io(link_path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair() -> DrivePair {
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
context: MountContext::System,
|
||||
owner_user: None,
|
||||
mount_point: "/media/smart-mount/pair-1".into(),
|
||||
local: LocalSide {
|
||||
kind: MountKind::Smb,
|
||||
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec!["vers=3.0".into()],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: MountKind::WebDav,
|
||||
host_or_url: "https://cloud.example.com/dav".into(),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_source_formats_smb_unc_path() {
|
||||
let pair = sample_pair();
|
||||
assert_eq!(
|
||||
local_source(&pair.local, &GlobalSettings::default()),
|
||||
"//192.168.1.10/share"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_source_uses_full_url_for_webdav() {
|
||||
let pair = sample_pair();
|
||||
assert_eq!(cloud_source(&pair.cloud), "https://cloud.example.com/dav");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_options_passes_flags_and_key_value_pairs_through_unchanged() {
|
||||
let opts = parse_options(&["vers=3.0".to_string(), "soft".to_string()]);
|
||||
assert_eq!(opts, vec!["vers=3.0".to_string(), "soft".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_injects_uid_gid_and_owner_only_modes_for_cifs() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let mut options = vec!["vers=3.0".to_string()];
|
||||
apply_owner_permissions(MountKind::Smb, Some(&user), &mut options).expect("apply");
|
||||
|
||||
assert!(options.contains(&"file_mode=0700".to_string()));
|
||||
assert!(options.contains(&"dir_mode=0700".to_string()));
|
||||
assert!(options.iter().any(|o| o.starts_with("uid=")));
|
||||
assert!(options.iter().any(|o| o.starts_with("gid=")));
|
||||
// vorhandene Option bleibt unangetastet
|
||||
assert!(options.contains(&"vers=3.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_respects_explicit_overrides() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let mut options = vec!["file_mode=0755".to_string()];
|
||||
apply_owner_permissions(MountKind::WebDav, Some(&user), &mut options).expect("apply");
|
||||
|
||||
assert!(options.contains(&"file_mode=0755".to_string()));
|
||||
assert!(!options.contains(&"file_mode=0700".to_string()));
|
||||
assert!(options.contains(&"dir_mode=0700".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_is_noop_for_nfs() {
|
||||
let mut options = vec![];
|
||||
apply_owner_permissions(MountKind::Nfs, Some("root"), &mut options).expect("apply");
|
||||
assert!(options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_is_noop_without_owner_user() {
|
||||
let mut options = vec![];
|
||||
apply_owner_permissions(MountKind::Smb, None, &mut options).expect("apply");
|
||||
assert!(options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_fails_clearly_for_unknown_user() {
|
||||
let mut options = vec![];
|
||||
let err = apply_owner_permissions(MountKind::Smb, Some("no-such-user-xyz"), &mut options)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_adds_soft_when_unset() {
|
||||
let mut options = vec![];
|
||||
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
|
||||
assert_eq!(options, vec!["soft".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_respects_explicit_hard() {
|
||||
let mut options = vec!["hard".to_string()];
|
||||
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
|
||||
assert_eq!(options, vec!["hard".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_respects_explicit_softerr() {
|
||||
let mut options = vec!["softerr".to_string()];
|
||||
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
|
||||
assert_eq!(options, vec!["softerr".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_is_noop_for_other_kinds() {
|
||||
let mut options = vec![];
|
||||
apply_nfs_resilience_defaults(MountKind::Smb, &mut options);
|
||||
assert!(options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_target_injects_soft_for_plain_nfs_pair() {
|
||||
let mut pair = sample_pair();
|
||||
pair.local.kind = MountKind::Nfs;
|
||||
pair.cloud.kind = MountKind::Nfs;
|
||||
let t = build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build");
|
||||
assert!(t.options.contains(&"soft".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backing_dirs_are_unique_per_side_and_live_next_to_mount_point() {
|
||||
let pair = sample_pair();
|
||||
let local = backing_dir(&pair, Side::Local);
|
||||
let cloud = backing_dir(&pair, Side::Cloud);
|
||||
assert_ne!(local, cloud);
|
||||
assert_eq!(local.parent(), pair.mount_point.parent());
|
||||
assert_eq!(cloud.parent(), pair.mount_point.parent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activate_symlink_points_at_the_right_backing_dir_and_is_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut pair = sample_pair();
|
||||
pair.mount_point = dir.path().join("pair-1");
|
||||
|
||||
activate_symlink(&pair, Side::Local).expect("activate local");
|
||||
assert_eq!(
|
||||
std::fs::read_link(&pair.mount_point).unwrap(),
|
||||
backing_dir(&pair, Side::Local)
|
||||
);
|
||||
|
||||
// Erneutes Aktivieren derselben Seite darf nicht fehlschlagen (Symlink wird ersetzt,
|
||||
// kein "existiert bereits als echtes Verzeichnis"-Fehler für einen eigenen Symlink).
|
||||
activate_symlink(&pair, Side::Local).expect("re-activate local");
|
||||
|
||||
activate_symlink(&pair, Side::Cloud).expect("activate cloud");
|
||||
assert_eq!(
|
||||
std::fs::read_link(&pair.mount_point).unwrap(),
|
||||
backing_dir(&pair, Side::Cloud)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activate_symlink_refuses_to_clobber_a_real_directory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut pair = sample_pair();
|
||||
pair.mount_point = dir.path().join("pair-1");
|
||||
std::fs::create_dir_all(&pair.mount_point).expect("create real dir");
|
||||
|
||||
let err = activate_symlink(&pair, Side::Local).unwrap_err();
|
||||
assert!(err.to_string().contains("real directory"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! WebDAV-Backend (davfs2).
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::db::credentials::Credential;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{
|
||||
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
|
||||
};
|
||||
|
||||
pub struct WebDavBackend;
|
||||
|
||||
const GUI_OPTIMIZE_COMMENT: &str =
|
||||
"# smart-mount: gui_optimize enabled (batches PROPFIND requests for a directory)";
|
||||
|
||||
/// Deutlich über dem Standardwert (16), siehe [`ensure_buf_size`] für die Begründung.
|
||||
const BUF_SIZE_KIB: &str = "16384";
|
||||
const BUF_SIZE_COMMENT: &str = "# smart-mount: buf_size increased so directory contents are listed reliably even with many files";
|
||||
|
||||
impl MountBackend for WebDavBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"davfs2"
|
||||
}
|
||||
|
||||
fn check_available(&self) -> Result<()> {
|
||||
if binary_available("mount.davfs") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BackendUnavailable {
|
||||
backend: "davfs2",
|
||||
reason: "'mount.davfs' not found - install package 'davfs2'".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> {
|
||||
let conf_path = davfs2_conf_path();
|
||||
ensure_gui_optimize(&conf_path)?;
|
||||
ensure_buf_size(&conf_path)?;
|
||||
if let Some(cred) = cred
|
||||
&& let Some(username) = &cred.username
|
||||
{
|
||||
write_secrets_entry(
|
||||
&davfs2_secrets_path(),
|
||||
&target.source,
|
||||
username,
|
||||
&cred.password,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
match target.invocation {
|
||||
MountInvocation::Direct => {
|
||||
cmd.arg("-t")
|
||||
.arg("davfs")
|
||||
.arg(&target.source)
|
||||
.arg(&target.mount_point);
|
||||
if !target.options.is_empty() {
|
||||
cmd.arg("-o").arg(target.options.join(","));
|
||||
}
|
||||
}
|
||||
MountInvocation::ViaFstab => {
|
||||
cmd.arg(&target.mount_point);
|
||||
}
|
||||
}
|
||||
run_tolerating_already_done(cmd, "davfs2 mount", false)
|
||||
}
|
||||
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("umount");
|
||||
cmd.arg(&target.mount_point);
|
||||
run_tolerating_already_done(cmd, "davfs2 umount", true)
|
||||
}
|
||||
}
|
||||
|
||||
fn davfs2_conf_path() -> PathBuf {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
PathBuf::from("/etc/davfs2/davfs2.conf")
|
||||
} else {
|
||||
home_dir().join(".davfs2/davfs2.conf")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn davfs2_secrets_path() -> PathBuf {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
PathBuf::from("/etc/davfs2/secrets")
|
||||
} else {
|
||||
home_dir().join(".davfs2/secrets")
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen
|
||||
/// Dateimanagern (die dazu neigen, jede Datei zu öffnen) die Reaktionszeit bei großen
|
||||
/// Verzeichnissen, indem die "ist eine neuere Version vorhanden?"-Abfrage für ein ganzes
|
||||
/// Verzeichnis in einem PROPFIND-Request gebündelt wird. Siehe `man davfs2.conf`.
|
||||
fn ensure_gui_optimize(path: &Path) -> Result<()> {
|
||||
ensure_config_line(path, "gui_optimize", "1", GUI_OPTIMIZE_COMMENT)
|
||||
}
|
||||
|
||||
/// Setzt `buf_size` in `davfs2.conf` idempotent auf einen deutlich über dem Standard (16
|
||||
/// KiB) liegenden Wert. Bekanntes Praxisproblem bei davfs2 (unabhängig davon, ob die
|
||||
/// `man davfs2.conf`-Beschreibung - reiner I/O-Geschwindigkeits-Tuningparameter - das exakt
|
||||
/// so vorsieht): bei zu kleinem `buf_size` liefert `ls` in Verzeichnissen mit vielen Dateien
|
||||
/// einen leeren/unvollständigen Inhalt zurück, obwohl einzelne Dateien direkt geöffnet werden
|
||||
/// können (der FUSE-readdir-Puffer wird dabei stillschweigend abgeschnitten). Ein deutlich
|
||||
/// größerer Puffer behebt das bei vertretbarem Speicher-Mehrverbrauch für einen einzelnen
|
||||
/// Mount.
|
||||
fn ensure_buf_size(path: &Path) -> Result<()> {
|
||||
ensure_config_line(path, "buf_size", BUF_SIZE_KIB, BUF_SIZE_COMMENT)
|
||||
}
|
||||
|
||||
/// Setzt `<key> <value>` in einer davfs2-Konfigurationsdatei idempotent: ersetzt eine
|
||||
/// bestehende (unkommentierte) Zeile mit demselben Schlüssel (unabhängig vom bisherigen
|
||||
/// Wert) statt sie zu duplizieren, und lässt alle anderen Zeilen unangetastet.
|
||||
fn ensure_config_line(path: &Path, key: &str, value: &str, comment: &str) -> Result<()> {
|
||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||
|
||||
let is_key_line = |line: &str| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.starts_with('#') && trimmed.split_whitespace().next() == Some(key)
|
||||
};
|
||||
|
||||
if existing
|
||||
.lines()
|
||||
.any(|l| is_key_line(l) && l.split_whitespace().nth(1) == Some(value))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut new_lines: Vec<String> = existing
|
||||
.lines()
|
||||
.filter(|l| !is_key_line(l))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
|
||||
new_lines.push(comment.to_string());
|
||||
new_lines.push(format!("{key} {value}"));
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", new_lines.join("\n"))).map_err(|e| Error::io(path, e))
|
||||
}
|
||||
|
||||
/// Schreibt/aktualisiert eine Zeile in davfs2s `secrets`-Datei (`<url> <username> <password>`,
|
||||
/// muss chmod 600 sein). Ersetzt eine bestehende Zeile für dieselbe URL statt sie zu duplizieren.
|
||||
fn write_secrets_entry(path: &PathBuf, url: &str, username: &str, password: &str) -> Result<()> {
|
||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||
let mut lines: Vec<String> = existing
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with(url))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
lines.push(format!("{url} {username} {password}"));
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut opts = OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let mut opts = OpenOptions::new();
|
||||
|
||||
let mut file = opts
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
file.write_all(format!("{}\n", lines.join("\n")).as_bytes())
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entfernt (best-effort) die Zeile für `url` aus der `secrets`-Datei, falls vorhanden - für
|
||||
/// `smart-mount drive remove`, damit kein Klartext-Passwort für ein gelöschtes Paar zurückbleibt.
|
||||
///
|
||||
/// **Bekannte Grenze:** die Zeile ist über die URL indiziert (inkl. IP bei MAC-adressierten
|
||||
/// lokalen Seiten). Hat sich die IP seit dem letzten Mount geändert, berechnet der Aufrufer
|
||||
/// eine andere, aktuelle URL als die tatsächlich gespeicherte - die eigentliche Alt-Zeile
|
||||
/// bleibt dann zurück. Kein Fehler, falls die Datei nicht existiert oder `url` nicht enthält.
|
||||
pub(crate) fn remove_secrets_entry(path: &Path, url: &str) -> Result<()> {
|
||||
let Ok(existing) = fs::read_to_string(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let remaining: Vec<&str> = existing
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with(url))
|
||||
.collect();
|
||||
if remaining.len() == existing.lines().count() {
|
||||
return Ok(());
|
||||
}
|
||||
fs::write(path, format!("{}\n", remaining.join("\n"))).map_err(|e| Error::io(path, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_gui_optimize_is_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
|
||||
ensure_gui_optimize(&path).expect("first call");
|
||||
let first = fs::read_to_string(&path).expect("read");
|
||||
ensure_gui_optimize(&path).expect("second call");
|
||||
let second = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
first
|
||||
.lines()
|
||||
.filter(|l| l.trim() == "gui_optimize 1")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_gui_optimize_replaces_disabled_value() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
fs::write(&path, "use_locks 1\ngui_optimize 0\n").expect("write");
|
||||
|
||||
ensure_gui_optimize(&path).expect("patch");
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert!(contents.contains("use_locks 1"));
|
||||
assert!(contents.contains("gui_optimize 1"));
|
||||
assert!(!contents.contains("gui_optimize 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_buf_size_raises_the_default_and_is_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
fs::write(&path, "buf_size 16\n").expect("write default");
|
||||
|
||||
ensure_buf_size(&path).expect("first call");
|
||||
let first = fs::read_to_string(&path).expect("read");
|
||||
ensure_buf_size(&path).expect("second call");
|
||||
let second = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert!(
|
||||
!first.contains("buf_size 16\n") && !first.lines().any(|l| l.trim() == "buf_size 16")
|
||||
);
|
||||
assert!(
|
||||
first
|
||||
.lines()
|
||||
.any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}"))
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.lines()
|
||||
.filter(|l| l.trim().starts_with("buf_size "))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_optimize_and_buf_size_coexist_without_clobbering_each_other() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
|
||||
ensure_gui_optimize(&path).expect("gui_optimize");
|
||||
ensure_buf_size(&path).expect("buf_size");
|
||||
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert!(contents.lines().any(|l| l.trim() == "gui_optimize 1"));
|
||||
assert!(
|
||||
contents
|
||||
.lines()
|
||||
.any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_secrets_entry_replaces_existing_line_for_same_url() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("secrets");
|
||||
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user", "old-pass").expect("write 1");
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user", "new-pass").expect("write 2");
|
||||
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert_eq!(
|
||||
contents
|
||||
.lines()
|
||||
.filter(|l| l.contains("https://cloud/dav"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(contents.contains("new-pass"));
|
||||
assert!(!contents.contains("old-pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_secrets_entry_deletes_only_the_matching_url() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("secrets");
|
||||
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user1", "pass1").expect("write 1");
|
||||
write_secrets_entry(&path, "http://192.168.1.5/dav", "user2", "pass2").expect("write 2");
|
||||
|
||||
remove_secrets_entry(&path, "https://cloud/dav").expect("remove");
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert!(!contents.contains("https://cloud/dav"));
|
||||
assert!(!contents.contains("pass1"));
|
||||
assert!(contents.contains("http://192.168.1.5/dav"));
|
||||
assert!(contents.contains("pass2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_secrets_entry_is_a_noop_for_missing_file_or_unmatched_url() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("does-not-exist");
|
||||
assert!(remove_secrets_entry(&path, "https://cloud/dav").is_ok());
|
||||
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user", "pass").expect("write");
|
||||
assert!(remove_secrets_entry(&path, "https://other/dav").is_ok());
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert!(contents.contains("https://cloud/dav"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Auflösung der konfigurierten lokalen Adresse (IP oder MAC) zu einer konkreten IPv4-Adresse.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::config::{GlobalSettings, LocalAddress};
|
||||
use crate::error::Result;
|
||||
use crate::network::mac2ip;
|
||||
|
||||
/// Löst eine [`LocalAddress`] zu einer konkreten IPv4-Adresse auf.
|
||||
///
|
||||
/// `Ip`-Adressen werden direkt durchgereicht, `Mac`-Adressen über das externe `mac2ip`-Tool
|
||||
/// aufgelöst (siehe [`mac2ip::resolve`]).
|
||||
pub fn resolve_ip(address: &LocalAddress, settings: &GlobalSettings) -> Result<Ipv4Addr> {
|
||||
match address {
|
||||
LocalAddress::Ip(ip) => Ok(*ip),
|
||||
LocalAddress::Mac(mac) => mac2ip::resolve(mac, &settings.mac2ip_binary),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Isolierter Wrapper um das externe, private `mac2ip`-CLI-Tool.
|
||||
//!
|
||||
//! `mac2ip` ist kein Rust-Crate, sondern ein eigenständiges, bereits vorhandenes CLI-Tool
|
||||
//! des Nutzers (siehe Projekt "Mac2Ip"). smart-mount ruft es ausschließlich als Subprozess
|
||||
//! auf; die JSON-Ausgabeform (`{"status":"ok","mac":..,"ip":..,"source":..}` bzw.
|
||||
//! `{"status":"error","mac":..,"error":..}`) wurde gegen den tatsächlichen Quellcode
|
||||
//! (`src/output.rs`) verifiziert.
|
||||
//!
|
||||
//! `--auto-trust-networks` wird immer mitgegeben: mac2ip beantwortet damit seine eigene
|
||||
//! "nmap-Scan in diesem Netzwerk erlauben?"-Rückfrage automatisch mit Ja und merkt sich das
|
||||
//! Netzwerk dauerhaft in seiner eigenen Cache-Datenbank - funktional identisch zum manuellen
|
||||
//! Eintragen in mac2ips Config, aber ohne dass smart-mount das Config-Schema eines fremden
|
||||
//! Tools kennen oder dort hineinschreiben muss.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::process::Command;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Mac2IpOutput {
|
||||
Success { ip: Ipv4Addr },
|
||||
Failure { error: String },
|
||||
}
|
||||
|
||||
/// Löst eine MAC-Adresse über das externe `mac2ip`-Tool zu einer IPv4-Adresse auf.
|
||||
///
|
||||
/// `binary` ist der konfigurierte Binary-Name/-Pfad (`GlobalSettings::mac2ip_binary`,
|
||||
/// standardmäßig `"mac2ip"`, per PATH aufgelöst).
|
||||
pub fn resolve(mac: &str, binary: &str) -> Result<Ipv4Addr> {
|
||||
let output = Command::new(binary)
|
||||
.args(["--json", "--auto-trust-networks", mac])
|
||||
.output()
|
||||
.map_err(|e| Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: format!("could not run '{binary}': {e}"),
|
||||
})?;
|
||||
|
||||
parse_output(&output.stdout, mac)
|
||||
}
|
||||
|
||||
/// Prüft, ob das konfigurierte `mac2ip`-Binary über `PATH` auffindbar ist.
|
||||
pub fn is_installed(binary: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(binary)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_output(stdout: &[u8], mac: &str) -> Result<Ipv4Addr> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
let line = text.lines().next().unwrap_or("").trim();
|
||||
|
||||
if line.is_empty() {
|
||||
return Err(Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: "no output received from mac2ip".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
match serde_json::from_str::<Mac2IpOutput>(line) {
|
||||
Ok(Mac2IpOutput::Success { ip }) => Ok(ip),
|
||||
Ok(Mac2IpOutput::Failure { error }) => Err(Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: error,
|
||||
}),
|
||||
Err(e) => Err(Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: format!("could not parse output as JSON: {e} (output: '{line}')"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_success_output() {
|
||||
let stdout =
|
||||
br#"{"status":"ok","mac":"aa:bb:cc:dd:ee:ff","ip":"192.168.1.42","source":"cache"}"#;
|
||||
let ip = parse_output(stdout, "aa:bb:cc:dd:ee:ff").expect("should parse");
|
||||
assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_failure_output_as_error() {
|
||||
let stdout =
|
||||
br#"{"status":"error","mac":"aa:bb:cc:dd:ee:ff","error":"keine IP-Adresse gefunden"}"#;
|
||||
let err = parse_output(stdout, "aa:bb:cc:dd:ee:ff").unwrap_err();
|
||||
assert!(matches!(err, Error::Mac2Ip { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_is_an_error_not_a_panic() {
|
||||
assert!(parse_output(b"", "aa:bb:cc:dd:ee:ff").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_output_is_an_error_not_a_panic() {
|
||||
assert!(parse_output(b"not json at all", "aa:bb:cc:dd:ee:ff").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Erreichbarkeitsprüfungen und lokale Adressauflösung.
|
||||
|
||||
pub mod address;
|
||||
pub mod mac2ip;
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Prüft, ob `addr` erreichbar ist. Erkennt anhand des Präfixes, ob es sich um eine URL
|
||||
/// (HTTP HEAD via `curl`) oder eine reine Host-/IP-Adresse (ICMP-Ping) handelt.
|
||||
///
|
||||
/// Portiert aus dem alten `src/network/utils.rs` (v0.2.0).
|
||||
pub fn is_reachable(addr: &str) -> bool {
|
||||
if addr.starts_with("http://") || addr.starts_with("https://") {
|
||||
Command::new("curl")
|
||||
.args(["--head", "--silent", "--fail", "--max-time", "5", addr])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
Command::new("ping")
|
||||
.args(["-c", "1", "-W", "2", addr])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Watchdog-Reconciling: entscheidet pro Laufwerkspaar, ob lokal oder Cloud eingebunden
|
||||
//! sein sollte, und schaltet bei Bedarf um.
|
||||
//!
|
||||
//! `watch_once` ist bewusst ein einzelner, synchroner Durchlauf ohne internen Sleep-Loop -
|
||||
//! dieselbe Kommandozeile (`smart-mount watch`) funktioniert dadurch sowohl unter einem
|
||||
//! systemd-Timer als auch als Crontab-Zeile.
|
||||
//!
|
||||
//! **Zu jedem Zeitpunkt ist im Ruhezustand genau eine Seite gemountet** (wie ursprünglich
|
||||
//! vorgesehen) - der sichtbare `pair.mount_point` ist ein Symlink auf das jeweils aktive
|
||||
//! Backing-Verzeichnis (siehe [`crate::mount::target`]). Nur *während* eines aktiven
|
||||
//! Umschaltens sind kurz beide Backing-Verzeichnisse gemountet: [`switch_to`] mountet die
|
||||
//! neue Seite zuerst, flippt dann den Symlink, und hängt erst danach die alte Seite aus - so
|
||||
//! gibt es nie ein Zeitfenster, in dem der sichtbare Pfad auf nichts Gemountetes zeigt.
|
||||
|
||||
use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide};
|
||||
use crate::db::credentials::{CredentialStore, Side};
|
||||
use crate::error::Result;
|
||||
use crate::mount::{self, lock, target};
|
||||
use crate::network::{self, address};
|
||||
|
||||
/// Ergebnis eines Reconcile-Durchlaufs für ein Paar.
|
||||
#[derive(Debug)]
|
||||
pub enum Action {
|
||||
NoOp,
|
||||
MountedLocal,
|
||||
MountedCloud,
|
||||
SwitchedToLocal,
|
||||
SwitchedToCloud,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReconcileOutcome {
|
||||
pub pair_id: String,
|
||||
pub pair_name: String,
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
/// Führt `reconcile_pair` für jedes aktivierte Paar in `cfg` aus.
|
||||
pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec<ReconcileOutcome> {
|
||||
let mut outcomes = Vec::with_capacity(cfg.pairs.len());
|
||||
for pair in cfg.pairs.iter().filter(|p| p.enabled) {
|
||||
outcomes.push(reconcile_pair(pair, &cfg.settings, creds).await);
|
||||
}
|
||||
outcomes
|
||||
}
|
||||
|
||||
/// Entscheidungstabelle (siehe Moduldoku): prüft Erreichbarkeit beider Seiten, vergleicht
|
||||
/// mit der aktuell aktiven Seite, und schaltet bei Bedarf um.
|
||||
pub async fn reconcile_pair(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
creds: &CredentialStore,
|
||||
) -> ReconcileOutcome {
|
||||
let pair_id = pair.id.clone();
|
||||
let pair_name = pair.name.clone();
|
||||
|
||||
match reconcile_pair_inner(pair, settings, creds).await {
|
||||
Ok(action) => ReconcileOutcome {
|
||||
pair_id,
|
||||
pair_name,
|
||||
action,
|
||||
},
|
||||
Err(e) => ReconcileOutcome {
|
||||
pair_id,
|
||||
pair_name,
|
||||
action: Action::Failed(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconcile_pair_inner(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
creds: &CredentialStore,
|
||||
) -> Result<Action> {
|
||||
let _guard = lock::acquire(&pair.id).await;
|
||||
|
||||
let local_reachable = check_local_reachable(&pair.local, settings);
|
||||
let active = target::active_side(pair);
|
||||
|
||||
if local_reachable {
|
||||
if active == Some(Side::Local) {
|
||||
return Ok(Action::NoOp);
|
||||
}
|
||||
switch_to(pair, settings, Side::Local, creds, active).await?;
|
||||
return Ok(if active.is_some() {
|
||||
Action::SwitchedToLocal
|
||||
} else {
|
||||
Action::MountedLocal
|
||||
});
|
||||
}
|
||||
|
||||
let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url);
|
||||
if cloud_reachable {
|
||||
if active == Some(Side::Cloud) {
|
||||
return Ok(Action::NoOp);
|
||||
}
|
||||
switch_to(pair, settings, Side::Cloud, creds, active).await?;
|
||||
return Ok(if active.is_some() {
|
||||
Action::SwitchedToCloud
|
||||
} else {
|
||||
Action::MountedCloud
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Action::NoOp)
|
||||
}
|
||||
|
||||
fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool {
|
||||
match address::resolve_ip(&local.address, settings) {
|
||||
Ok(ip) => network::is_reachable(&ip.to_string()),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mountet `new_side` zuerst, flippt danach den sichtbaren Symlink, und hängt erst zum
|
||||
/// Schluss `old_active` (falls vorhanden und verschieden) aus. Diese Reihenfolge stellt
|
||||
/// sicher, dass der sichtbare `pair.mount_point` nie auf ein gerade ausgehängtes oder noch
|
||||
/// nicht bereites Backing-Verzeichnis zeigt.
|
||||
async fn switch_to(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
new_side: Side,
|
||||
creds: &CredentialStore,
|
||||
old_active: Option<Side>,
|
||||
) -> Result<()> {
|
||||
mount_side(pair, settings, new_side, creds).await?;
|
||||
target::activate_symlink(pair, new_side)?;
|
||||
|
||||
if let Some(old_side) = old_active
|
||||
&& old_side != new_side
|
||||
{
|
||||
unmount_side(pair, settings, old_side).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mount_side(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
side: Side,
|
||||
creds: &CredentialStore,
|
||||
) -> Result<()> {
|
||||
let mount_target = target::build_target(pair, settings, side)?;
|
||||
std::fs::create_dir_all(&mount_target.mount_point)
|
||||
.map_err(|e| crate::error::Error::io(&mount_target.mount_point, e))?;
|
||||
|
||||
let backend = mount::backend_for(target::side_kind(pair, side));
|
||||
backend.check_available()?;
|
||||
let cred = creds.get(&pair.id, side).await?;
|
||||
backend.prepare(&mount_target, cred.as_ref())?;
|
||||
backend.mount(&mount_target)
|
||||
}
|
||||
|
||||
/// Hängt aus, was aktuell aktiv ist (siehe [`target::active_side`]), unabhängig von der
|
||||
/// Erreichbarkeit - für `smart-mount unmount`. Der Symlink bleibt bestehen (zeigt danach auf
|
||||
/// ein leeres, ausgehängtes Backing-Verzeichnis) - der nächste `mount`/`watch`-Lauf räumt das
|
||||
/// beim erneuten Aktivieren automatisch auf.
|
||||
pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result<Action> {
|
||||
let _guard = lock::acquire(&pair.id).await;
|
||||
|
||||
let Some(side) = target::active_side(pair) else {
|
||||
return Ok(Action::NoOp);
|
||||
};
|
||||
unmount_side(pair, settings, side).await?;
|
||||
Ok(Action::NoOp)
|
||||
}
|
||||
|
||||
async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -> Result<()> {
|
||||
let mount_target = target::build_target(pair, settings, side)?;
|
||||
mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{CloudSide, MountContext, MountKind};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair {
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
context: MountContext::System,
|
||||
owner_user: None,
|
||||
mount_point: "/media/smart-mount/pair-1".into(),
|
||||
local: LocalSide {
|
||||
kind: local_kind,
|
||||
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec!["vers=3.0".into()],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: cloud_kind,
|
||||
host_or_url: "https://cloud.example.com/dav".into(),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_source_formats_smb_unc_path() {
|
||||
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
|
||||
let source = target::local_source(&pair.local, &GlobalSettings::default());
|
||||
assert_eq!(source, "//192.168.1.10/share");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_target_uses_backing_dir_not_visible_mount_point() {
|
||||
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
|
||||
let t =
|
||||
target::build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build");
|
||||
assert_ne!(t.mount_point, pair.mount_point);
|
||||
assert_eq!(t.mount_point, target::backing_dir(&pair, Side::Local));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Generiert/installiert systemd-Units (System- und User-Kontext) sowie das Crontab-Äquivalent
|
||||
//! für Systeme ohne systemd.
|
||||
//!
|
||||
//! Die systemd-Units rufen ausschließlich einfache `smart-mount`-Subcommands auf, damit
|
||||
//! dieselben Zeilen 1:1 als Crontab-Einträge funktionieren.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const MOUNT_SERVICE: &str = "smart-mount-mount.service";
|
||||
const WATCH_SERVICE: &str = "smart-mount-watch.service";
|
||||
const WATCH_TIMER: &str = "smart-mount-watch.timer";
|
||||
|
||||
const CRON_D_PATH: &str = "/etc/cron.d/smart-mount";
|
||||
const CRON_BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
|
||||
const CRON_END_MARKER: &str = "# END smart-mount managed block";
|
||||
|
||||
/// System (root) oder User-Kontext für die Unit-Installation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
System,
|
||||
User,
|
||||
}
|
||||
|
||||
fn binary_path() -> String {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "/usr/bin/smart-mount".to_string())
|
||||
}
|
||||
|
||||
fn mount_service_unit() -> String {
|
||||
format!(
|
||||
"[Unit]\nDescription=smart-mount: mount configured drive pairs at boot\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart={} mount --all\n\n[Install]\nWantedBy=multi-user.target\n",
|
||||
binary_path()
|
||||
)
|
||||
}
|
||||
|
||||
fn watch_service_unit() -> String {
|
||||
format!(
|
||||
"[Unit]\nDescription=smart-mount: check reachability and switch local/cloud if needed\n\n[Service]\nType=oneshot\nExecStart={} watch\n",
|
||||
binary_path()
|
||||
)
|
||||
}
|
||||
|
||||
fn watch_timer_unit(interval_secs: u64) -> String {
|
||||
format!(
|
||||
"[Unit]\nDescription=smart-mount: periodic reconciling\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec={interval_secs}s\nPersistent=true\nUnit={WATCH_SERVICE}\n\n[Install]\nWantedBy=timers.target\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn unit_dir(scope: Scope) -> Result<PathBuf> {
|
||||
match scope {
|
||||
Scope::System => {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
return Err(Error::RequiresRoot("service install --system"));
|
||||
}
|
||||
Ok(PathBuf::from("/etc/systemd/system"))
|
||||
}
|
||||
Scope::User => {
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or(Error::Other("HOME not set".to_string()))?;
|
||||
Ok(home.join(".config/systemd/user"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn systemctl(scope: Scope, args: &[&str]) -> Result<()> {
|
||||
let mut cmd = Command::new("systemctl");
|
||||
if scope == Scope::User {
|
||||
cmd.arg("--user");
|
||||
}
|
||||
cmd.args(args);
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| Error::Other(format!("could not run systemctl: {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Other(format!(
|
||||
"systemctl {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schreibt die Unit-Dateien, lädt systemd neu und aktiviert Mount- und Watch-Timer-Unit.
|
||||
pub fn install(scope: Scope, watch_interval_secs: u64) -> Result<()> {
|
||||
let dir = unit_dir(scope)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
|
||||
|
||||
std::fs::write(dir.join(MOUNT_SERVICE), mount_service_unit())
|
||||
.map_err(|e| Error::io(&dir, e))?;
|
||||
std::fs::write(dir.join(WATCH_SERVICE), watch_service_unit())
|
||||
.map_err(|e| Error::io(&dir, e))?;
|
||||
std::fs::write(dir.join(WATCH_TIMER), watch_timer_unit(watch_interval_secs))
|
||||
.map_err(|e| Error::io(&dir, e))?;
|
||||
|
||||
systemctl(scope, &["daemon-reload"])?;
|
||||
systemctl(scope, &["enable", "--now", MOUNT_SERVICE, WATCH_TIMER])?;
|
||||
|
||||
if scope == Scope::User {
|
||||
logger_ctdra::info(
|
||||
"systemd",
|
||||
"For boot-time operation without an active login session: run 'loginctl enable-linger <user>'. \
|
||||
Note: in that case the OS keyring may not yet be available at boot - \
|
||||
smart-mount then automatically falls back to the key file.",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ob `systemctl` auf diesem System überhaupt vorhanden ist - Voraussetzung, bevor
|
||||
/// [`install`]/[`uninstall`] sinnvoll aufgerufen werden können.
|
||||
pub fn is_available() -> bool {
|
||||
crate::mount::binary_available("systemctl")
|
||||
}
|
||||
|
||||
/// Ergebnis von [`uninstall`].
|
||||
pub enum SystemdUninstallOutcome {
|
||||
/// Mindestens eine Unit-Datei war vorhanden und wurde entfernt.
|
||||
Removed,
|
||||
/// Keine der Unit-Dateien war vorhanden - nichts zu tun.
|
||||
NotPresent,
|
||||
}
|
||||
|
||||
/// Deaktiviert und entfernt die Unit-Dateien, falls vorhanden.
|
||||
pub fn uninstall(scope: Scope) -> Result<SystemdUninstallOutcome> {
|
||||
let dir = unit_dir(scope)?;
|
||||
let units = [MOUNT_SERVICE, WATCH_SERVICE, WATCH_TIMER];
|
||||
|
||||
if !units.iter().any(|unit| dir.join(unit).exists()) {
|
||||
return Ok(SystemdUninstallOutcome::NotPresent);
|
||||
}
|
||||
|
||||
let _ = systemctl(scope, &["disable", "--now", MOUNT_SERVICE, WATCH_TIMER]);
|
||||
|
||||
for unit in units {
|
||||
let path = dir.join(unit);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path).map_err(|e| Error::io(&path, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
systemctl(scope, &["daemon-reload"])?;
|
||||
Ok(SystemdUninstallOutcome::Removed)
|
||||
}
|
||||
|
||||
/// Erzeugt die Crontab-Äquivalente zu den generierten Units, für Systeme ohne systemd.
|
||||
/// `watch_interval_secs` ist derselbe Wert wie `settings.watch_interval_secs`, der auch die
|
||||
/// `OnUnitActiveSec`-Periode des systemd-Timers steuert - beide Wege sollen dieselbe Kadenz
|
||||
/// ergeben, statt dass die Crontab-Variante einen unabhängigen, fest eingebauten Wert hat.
|
||||
pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
|
||||
let bin = binary_path();
|
||||
let schedule = cron_schedule_for_interval(watch_interval_secs);
|
||||
format!("@reboot {bin} mount --all\n{schedule} {bin} watch\n")
|
||||
}
|
||||
|
||||
/// Ergebnis von [`install_cron`].
|
||||
pub enum CronInstallOutcome {
|
||||
/// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`).
|
||||
SystemFile(PathBuf),
|
||||
/// Persönliche Crontab des aufrufenden Nutzers aktualisiert.
|
||||
UserCrontab,
|
||||
/// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer
|
||||
/// sollte stattdessen [`crontab_equivalent`] anzeigen.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Richtet die periodische Ausführung direkt über Cron ein (Alternative zu [`install`] für
|
||||
/// Systeme ohne systemd), sofern ein Cron-Mechanismus gefunden wird - sonst [`CronInstallOutcome::Unavailable`]
|
||||
/// statt eines Fehlers, der Aufrufer zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung.
|
||||
///
|
||||
/// `Scope::System` schreibt `/etc/cron.d/smart-mount` (Standard-Konvention für
|
||||
/// paketverwaltete Cron-Einträge, läuft als root; erfordert Root-Rechte, kein
|
||||
/// Self-Elevate - analog zu `install(Scope::System, ...)`). `Scope::User` aktualisiert die
|
||||
/// persönliche Crontab des aufrufenden Nutzers über `crontab -l`/`crontab -`, mit demselben
|
||||
/// verwalteten-Block-Muster wie `fstab::setup` für `/etc/fstab` - bestehende, unabhängige
|
||||
/// Cron-Einträge bleiben unangetastet.
|
||||
pub fn install_cron(scope: Scope, watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
match scope {
|
||||
Scope::System => install_cron_system(watch_interval_secs),
|
||||
Scope::User => install_cron_user(watch_interval_secs),
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_cron_block(watch_interval_secs: u64, user_field: Option<&str>) -> String {
|
||||
let bin = binary_path();
|
||||
let schedule = cron_schedule_for_interval(watch_interval_secs);
|
||||
let user_prefix = user_field.map(|u| format!("{u} ")).unwrap_or_default();
|
||||
format!(
|
||||
"{CRON_BEGIN_MARKER}\n@reboot {user_prefix}{bin} mount --all\n{schedule} {user_prefix}{bin} watch\n{CRON_END_MARKER}\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn install_cron_system(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
return Err(Error::RequiresRoot("service crontab (system context)"));
|
||||
}
|
||||
if !Path::new("/etc/cron.d").is_dir() {
|
||||
return Ok(CronInstallOutcome::Unavailable);
|
||||
}
|
||||
|
||||
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root,
|
||||
// passend dazu, dass System-Kontext-Paare auch sonst als root gemountet werden.
|
||||
let contents = managed_cron_block(watch_interval_secs, Some("root"));
|
||||
std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(CRON_D_PATH, std::fs::Permissions::from_mode(0o644))
|
||||
.map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
}
|
||||
Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH)))
|
||||
}
|
||||
|
||||
fn install_cron_user(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
if !crate::mount::binary_available("crontab") {
|
||||
return Ok(CronInstallOutcome::Unavailable);
|
||||
}
|
||||
|
||||
let existing = read_current_user_crontab();
|
||||
let without_block =
|
||||
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER);
|
||||
let block = managed_cron_block(watch_interval_secs, None);
|
||||
let new_contents = format!("{}\n{block}", without_block.trim_end());
|
||||
|
||||
write_user_crontab(&new_contents)?;
|
||||
Ok(CronInstallOutcome::UserCrontab)
|
||||
}
|
||||
|
||||
/// Ergebnis von [`uninstall_cron`].
|
||||
pub enum CronUninstallOutcome {
|
||||
/// Ein verwalteter Cron-Eintrag wurde gefunden und entfernt.
|
||||
Removed,
|
||||
/// Kein von smart-mount verwalteter Cron-Eintrag vorhanden - nichts zu tun.
|
||||
NotPresent,
|
||||
}
|
||||
|
||||
/// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten
|
||||
/// Cron-Eintrag wieder, sofern vorhanden. `Scope::User` rührt dabei - wie `install_cron` -
|
||||
/// nur den von smart-mount verwalteten Block in der persönlichen Crontab an, keine
|
||||
/// unabhängigen, bereits vorhandenen Einträge.
|
||||
pub fn uninstall_cron(scope: Scope) -> Result<CronUninstallOutcome> {
|
||||
match scope {
|
||||
Scope::System => uninstall_cron_system(),
|
||||
Scope::User => uninstall_cron_user(),
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_cron_system() -> Result<CronUninstallOutcome> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
return Err(Error::RequiresRoot(
|
||||
"service uninstall (system context, cron)",
|
||||
));
|
||||
}
|
||||
let path = Path::new(CRON_D_PATH);
|
||||
if !path.exists() {
|
||||
return Ok(CronUninstallOutcome::NotPresent);
|
||||
}
|
||||
std::fs::remove_file(path).map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
Ok(CronUninstallOutcome::Removed)
|
||||
}
|
||||
|
||||
fn uninstall_cron_user() -> Result<CronUninstallOutcome> {
|
||||
if !crate::mount::binary_available("crontab") {
|
||||
return Ok(CronUninstallOutcome::NotPresent);
|
||||
}
|
||||
let existing = read_current_user_crontab();
|
||||
if !existing.contains(CRON_BEGIN_MARKER) {
|
||||
return Ok(CronUninstallOutcome::NotPresent);
|
||||
}
|
||||
let without_block =
|
||||
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER);
|
||||
write_user_crontab(without_block.trim_end())?;
|
||||
Ok(CronUninstallOutcome::Removed)
|
||||
}
|
||||
|
||||
/// `crontab -l` meldet für einen Nutzer ohne bestehende Crontab einen Fehler ("no crontab for
|
||||
/// ...") - das ist der Normalfall bei der ersten Einrichtung, kein echter Fehler.
|
||||
fn read_current_user_crontab() -> String {
|
||||
Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_user_crontab(contents: &str) -> Result<()> {
|
||||
let mut child = Command::new("crontab")
|
||||
.arg("-")
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| Error::Other(format!("could not start 'crontab': {e}")))?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| Error::Other("stdin of 'crontab -' not available".to_string()))?
|
||||
.write_all(contents.as_bytes())
|
||||
.map_err(|e| Error::Other(format!("writing to 'crontab -' failed: {e}")))?;
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| Error::Other(format!("'crontab -' failed: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(Error::Other("'crontab -' reported an error".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rechnet ein Sekunden-Intervall in einen `*/N`-artigen Cron-Ausdruck um. Crons Granularität
|
||||
/// ist Minuten (keine Sekunden) - es wird auf die nächste Minute gerundet, mindestens 1
|
||||
/// (Cron kann nicht häufiger als minütlich auslösen). Ab 60 Minuten wird auf Stunden
|
||||
/// umgestellt (`0 */N * * *`); Intervalle über 24h werden grob als "täglich um Mitternacht"
|
||||
/// angenähert, da ein reiner `*/N`-Ausdruck das nicht mehr sauber abbilden kann.
|
||||
fn cron_schedule_for_interval(interval_secs: u64) -> String {
|
||||
let minutes = ((interval_secs as f64 / 60.0).round() as u64).max(1);
|
||||
if minutes <= 59 {
|
||||
format!("*/{minutes} * * * *")
|
||||
} else {
|
||||
let hours = ((minutes as f64 / 60.0).round() as u64).max(1);
|
||||
if hours <= 23 {
|
||||
format!("0 */{hours} * * *")
|
||||
} else {
|
||||
"0 0 * * *".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn managed_cron_block_for_user_crontab_has_no_user_field() {
|
||||
let block = managed_cron_block(120, None);
|
||||
assert!(block.starts_with(CRON_BEGIN_MARKER));
|
||||
assert!(block.trim_end().ends_with(CRON_END_MARKER));
|
||||
assert!(block.contains("@reboot") && !block.contains("@reboot root"));
|
||||
assert!(block.contains("mount --all"));
|
||||
assert!(block.contains("*/2 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_cron_block_for_system_cron_d_includes_user_field() {
|
||||
let block = managed_cron_block(120, Some("root"));
|
||||
assert!(block.contains("@reboot root "));
|
||||
assert!(block.contains("*/2 * * * * root "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_watch_interval_maps_to_every_two_minutes() {
|
||||
assert_eq!(cron_schedule_for_interval(120), "*/2 * * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounds_to_the_nearest_minute() {
|
||||
assert_eq!(cron_schedule_for_interval(90), "*/2 * * * *");
|
||||
assert_eq!(cron_schedule_for_interval(80), "*/1 * * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_minute_intervals_clamp_to_one_minute() {
|
||||
assert_eq!(cron_schedule_for_interval(30), "*/1 * * * *");
|
||||
assert_eq!(cron_schedule_for_interval(1), "*/1 * * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_to_hourly_expression_above_59_minutes() {
|
||||
assert_eq!(cron_schedule_for_interval(60 * 90), "0 */2 * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_long_intervals_fall_back_to_daily_at_midnight() {
|
||||
assert_eq!(cron_schedule_for_interval(60 * 60 * 30), "0 0 * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crontab_equivalent_includes_both_reboot_and_watch_lines() {
|
||||
let output = crontab_equivalent(120);
|
||||
assert!(output.contains("@reboot"));
|
||||
assert!(output.contains("mount --all"));
|
||||
assert!(output.contains("*/2 * * * *"));
|
||||
assert!(output.contains("watch"));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
//! Kleine, modulübergreifend geteilte Hilfsfunktionen.
|
||||
|
||||
/// Entfernt einen durch `begin_marker`/`end_marker` abgegrenzten Abschnitt aus `contents`
|
||||
/// (Marker-Zeilen selbst eingeschlossen). Für das "verwalteter Block"-Muster, mit dem
|
||||
/// smart-mount eigene Zeilen in einer fremden Datei (`/etc/fstab`, Crontab) aktualisiert,
|
||||
/// ohne bestehende, unabhängige Einträge anzurühren - siehe [`crate::fstab`] und
|
||||
/// [`crate::systemd`].
|
||||
pub(crate) fn strip_managed_block(contents: &str, begin_marker: &str, end_marker: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut inside = false;
|
||||
for line in contents.lines() {
|
||||
if line.trim() == begin_marker {
|
||||
inside = true;
|
||||
continue;
|
||||
}
|
||||
if line.trim() == end_marker {
|
||||
inside = false;
|
||||
continue;
|
||||
}
|
||||
if !inside {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removes_only_the_marked_section() {
|
||||
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
|
||||
let stripped = strip_managed_block(contents, "# BEGIN test", "# END test");
|
||||
assert_eq!(stripped, "line1\nline2\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_a_noop_when_markers_are_absent() {
|
||||
let contents = "line1\nline2\n";
|
||||
assert_eq!(
|
||||
strip_managed_block(contents, "# BEGIN test", "# END test"),
|
||||
contents
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_content_before_the_first_marker_and_no_trailing_content() {
|
||||
let contents = "keep-me\n# BEGIN x\ndrop-me\n# END x\n";
|
||||
assert_eq!(
|
||||
strip_managed_block(contents, "# BEGIN x", "# END x"),
|
||||
"keep-me\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user