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:
2026-09-15 00:50:20 +02:00
co-authored by Claude Sonnet 5
parent cd290f7f5b
commit b64b81a5c1
35 changed files with 8701 additions and 338 deletions
Generated
+3714
View File
File diff suppressed because it is too large Load Diff
+75 -14
View File
@@ -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",
]
+50
View File
@@ -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(())
}
+607
View File
@@ -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
View File
@@ -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(())
}
}
}
+82
View File
@@ -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);
}
+152
View File
@@ -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(())
}
}
}
+21
View File
@@ -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(())
}
}
}
+98
View File
@@ -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())
}
+26
View File
@@ -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(())
}
+15
View File
@@ -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");
}
+51
View File
@@ -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()))
}
+221
View File
@@ -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));
}
}
}
+151
View File
@@ -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)
}
}
+84
View File
@@ -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());
}
}
+140
View File
@@ -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(())
}
}
+53
View File
@@ -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
View File
@@ -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"));
}
}
+59
View File
@@ -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,
}
}
}
+425
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
}
+31
View File
@@ -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
}
+257
View File
@@ -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());
}
}
+59
View File
@@ -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)
}
}
+147
View File
@@ -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);
}
}
+76
View File
@@ -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"));
}
}
+444
View File
@@ -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"));
}
}
+356
View File
@@ -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"));
}
}
+18
View File
@@ -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),
}
}
+107
View File
@@ -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());
}
}
+30
View File
@@ -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)
}
}
+221
View File
@@ -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));
}
}
+392
View File
@@ -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
View File
@@ -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"
);
}
}