Initial commit
This commit is contained in:
108
src/cache.rs
Normal file
108
src/cache.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use chrono::Duration;
|
||||
use chrono;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CacheEntry {
|
||||
uuid: String,
|
||||
changed: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
pub fn changed(&self) -> chrono::DateTime<chrono::Utc> {
|
||||
self.changed
|
||||
}
|
||||
|
||||
pub fn expired(&self, ttl: Duration) -> bool {
|
||||
let now = chrono::Utc::now();
|
||||
now > self.changed + ttl
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Cache {
|
||||
paths: HashMap<String, CacheEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Io(io::Error),
|
||||
Json(serde_json::Error),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn message(&self) -> String {
|
||||
match *self {
|
||||
Self::Io(ref e) => format!("{}", e),
|
||||
Self::Json(ref e) => format!("{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Json(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str(&self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
paths: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Result<Self, Error> {
|
||||
if let Ok(mut file) = fs::File::open(path) {
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let cache = serde_json::from_str(&contents);
|
||||
match cache {
|
||||
Ok(c) => Ok(c),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
Ok(Self::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &str) -> Option<&CacheEntry> {
|
||||
if let Some(entry) = self.paths.get(path) {
|
||||
return Some(&entry);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn update(&mut self, path: &str, uuid: &str) {
|
||||
let entry = CacheEntry {
|
||||
uuid: uuid.into(),
|
||||
changed: chrono::Utc::now(),
|
||||
};
|
||||
self.paths.insert(path.into(), entry);
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &str) -> Result<(), Error> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
let contents = serde_json::to_string(&self)?;
|
||||
file.write_all(&contents.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
51
src/error.rs
Normal file
51
src/error.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Io(io::Error),
|
||||
Udev(libudev::Error),
|
||||
NotMounted,
|
||||
NotFound,
|
||||
InvalidUuid,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn message(&self) -> String {
|
||||
match *self {
|
||||
Self::Io(ref e) => format!("{}", e),
|
||||
Self::Udev(ref e) => format!("{}", e),
|
||||
Self::NotMounted => "Path is not a mount point".into(),
|
||||
Self::NotFound => "Device not found".into(),
|
||||
Self::InvalidUuid => "Invalid device UUID".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str(&self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<libudev::Error> for Error {
|
||||
fn from(err: libudev::Error) -> Self {
|
||||
Self::Udev(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for io::Error {
|
||||
fn from(error: Error) -> Self {
|
||||
match error {
|
||||
Error::Io(e) => e,
|
||||
Error::Udev(e) => e.into(),
|
||||
_ => io::Error::new(io::ErrorKind::Other, error.message()),
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/main.rs
Normal file
165
src/main.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
extern crate chrono;
|
||||
extern crate clap;
|
||||
extern crate libudev;
|
||||
extern crate serde;
|
||||
extern crate serde_json;
|
||||
|
||||
use chrono::Duration;
|
||||
use clap::App;
|
||||
use clap::Arg;
|
||||
use std::env;
|
||||
use std::io::prelude::*;
|
||||
use std::num::ParseIntError;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
mod cache;
|
||||
mod error;
|
||||
mod mountinfo;
|
||||
|
||||
use mountinfo::get_fs_uuid;
|
||||
|
||||
const CACHE_FILENAME: &'static str = "mizule.json";
|
||||
|
||||
fn validate_int(v: String) -> Result<(), String> {
|
||||
let i: Result<i64, ParseIntError> = v.parse();
|
||||
match i {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Invalid number: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let matches = App::new("Mizule")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.author(env!("CARGO_PKG_AUTHORS"))
|
||||
.about("Warns when the same filesystem has been mounted too long")
|
||||
.arg(
|
||||
Arg::with_name("mountpoint")
|
||||
.help("Path to the mounted filesystem to check")
|
||||
.required(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("ttl")
|
||||
.short("t")
|
||||
.long("ttl")
|
||||
.takes_value(true)
|
||||
.validator(validate_int)
|
||||
.default_value("30")
|
||||
.help("Number of days before warning"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("mailto")
|
||||
.short("m")
|
||||
.long("mailto")
|
||||
.alias("mail-to")
|
||||
.takes_value(true)
|
||||
.help("Send warning to this email address"),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
let mountpoint = matches.value_of("mountpoint").unwrap();
|
||||
let ttl: i64 = matches.value_of("ttl").unwrap().parse().unwrap();
|
||||
let ttl = Duration::days(ttl);
|
||||
let mailto = matches.value_of("mailto");
|
||||
match get_fs_uuid(mountpoint) {
|
||||
Ok(uuid) => {
|
||||
check_and_notify(mountpoint, &uuid, ttl, mailto);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error getting filesystem UUID: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_and_notify(
|
||||
mountpoint: &str,
|
||||
uuid: &str,
|
||||
ttl: Duration,
|
||||
mailto: Option<&str>,
|
||||
) {
|
||||
let mut cache_path = match env::var("XDG_CACHE_HOME") {
|
||||
Ok(v) => PathBuf::from(&v),
|
||||
Err(_) => match env::var("HOME") {
|
||||
Ok(v) => [v, ".cache".into()].iter().collect(),
|
||||
Err(_) => ".".into(),
|
||||
},
|
||||
};
|
||||
cache_path.push(CACHE_FILENAME);
|
||||
let cache_path = cache_path.to_str().unwrap();
|
||||
|
||||
let cache = cache::Cache::load(cache_path);
|
||||
match cache {
|
||||
Ok(mut cache) => {
|
||||
if let Some(entry) = cache.get(&mountpoint) {
|
||||
if entry.expired(ttl) {
|
||||
notify(mountpoint, uuid, entry.changed(), mailto);
|
||||
}
|
||||
} else {
|
||||
cache.update(&mountpoint, &uuid);
|
||||
if let Err(e) = cache.save(&cache_path) {
|
||||
eprintln!("Failed to save cache: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error loading cache: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notify(
|
||||
mountpoint: &str,
|
||||
uuid: &str,
|
||||
changed: chrono::DateTime<chrono::Utc>,
|
||||
mailto: Option<&str>,
|
||||
) {
|
||||
let now = chrono::Utc::now();
|
||||
let delta = now - changed;
|
||||
let message = format!(
|
||||
concat!(
|
||||
"The filesystem mounted at {} (UUID {}) ",
|
||||
"was last changed on {} ({} days ago)",
|
||||
),
|
||||
mountpoint,
|
||||
uuid,
|
||||
changed,
|
||||
delta.num_days()
|
||||
);
|
||||
println!("{}", message);
|
||||
if let Some(mailto) = mailto {
|
||||
println!("Sending notification to {}", mailto);
|
||||
let subject = format!(
|
||||
"{}: {} needs to be changed!",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
mountpoint
|
||||
);
|
||||
if let Err(e) = sendmail(mailto, &subject, &message) {
|
||||
eprintln!("Failed to send email: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sendmail(
|
||||
mailto: &str,
|
||||
subject: &str,
|
||||
message: &str,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let mut cmd = process::Command::new("sendmail")
|
||||
.arg("-t")
|
||||
.stdin(process::Stdio::piped())
|
||||
.spawn()?;
|
||||
{
|
||||
let mut stdin = cmd.stdin.take().unwrap();
|
||||
stdin.write_all(format!("To: {}\n", mailto).as_bytes())?;
|
||||
stdin.write_all(format!("Subject: {}\n", subject).as_bytes())?;
|
||||
stdin.write_all("\n".as_bytes())?;
|
||||
stdin.write_all(message.as_bytes())?;
|
||||
stdin.write_all("\n".as_bytes())?;
|
||||
stdin.flush()?;
|
||||
}
|
||||
cmd.wait()?;
|
||||
Ok(())
|
||||
}
|
||||
144
src/mountinfo.rs
Normal file
144
src/mountinfo.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::path;
|
||||
|
||||
use error::Error;
|
||||
|
||||
pub struct MountInfo {
|
||||
pub mount_id: u32,
|
||||
pub parent_id: u32,
|
||||
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
|
||||
pub root: String,
|
||||
pub mount_point: String,
|
||||
pub mount_opts: String,
|
||||
|
||||
pub fields: String,
|
||||
|
||||
pub fstype: String,
|
||||
pub source: String,
|
||||
pub super_options: String,
|
||||
}
|
||||
|
||||
impl MountInfo {
|
||||
pub fn from_line(line: String) -> Self {
|
||||
let mut parts = line.split_whitespace();
|
||||
let mount_id = parts.next().unwrap().parse().expect("invalid mount id");
|
||||
let parent_id =
|
||||
parts.next().unwrap().parse().expect("invalid parent id");
|
||||
|
||||
let mut majmin = parts.next().unwrap().split(":");
|
||||
let major = majmin
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.expect("invalid major number");
|
||||
let minor = majmin
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.expect("invalid minor number");
|
||||
|
||||
let root = parts.next().unwrap().into();
|
||||
let mount_point = parts.next().unwrap().into();
|
||||
let mount_opts = parts.next().unwrap().into();
|
||||
|
||||
let mut fields = String::new();
|
||||
loop {
|
||||
let next = parts.next().unwrap();
|
||||
if next == "-" {
|
||||
break;
|
||||
} else {
|
||||
fields.push_str(" ");
|
||||
fields.push_str(next);
|
||||
}
|
||||
}
|
||||
|
||||
let fstype = parts.next().unwrap().into();
|
||||
let source = parts.next().unwrap().into();
|
||||
let super_options = parts.next().unwrap().into();
|
||||
|
||||
MountInfo {
|
||||
mount_id,
|
||||
parent_id,
|
||||
major,
|
||||
minor,
|
||||
root,
|
||||
mount_point,
|
||||
mount_opts,
|
||||
fields,
|
||||
fstype,
|
||||
source,
|
||||
super_options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MountInfoIterator {
|
||||
reader: io::BufReader<fs::File>,
|
||||
}
|
||||
|
||||
impl Iterator for MountInfoIterator {
|
||||
type Item = MountInfo;
|
||||
|
||||
fn next(&mut self) -> Option<MountInfo> {
|
||||
let mut line = String::new();
|
||||
let n = self.reader.read_line(&mut line);
|
||||
match n {
|
||||
Ok(0) => None,
|
||||
Ok(_) => Some(MountInfo::from_line(line)),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mountinfo() -> std::io::Result<MountInfoIterator> {
|
||||
let file = fs::File::open("/proc/self/mountinfo")?;
|
||||
Ok(MountInfoIterator {
|
||||
reader: io::BufReader::new(file),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mountinfo(mountpoint: &str) -> Option<MountInfo> {
|
||||
if let Ok(mounts) = mountinfo() {
|
||||
for mi in mounts {
|
||||
if mi.mount_point == mountpoint {
|
||||
return Some(mi);
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
pub fn get_fs_uuid(mountpoint: &str) -> Result<String, Error> {
|
||||
if let Some(mi) = get_mountinfo(mountpoint) {
|
||||
let udev = libudev::Context::new()?;
|
||||
let mut enumerator = libudev::Enumerator::new(&udev)?;
|
||||
let mut realpath = path::PathBuf::from(&mi.source);
|
||||
let stat = fs::symlink_metadata(&mi.source)?;
|
||||
let file_type = stat.file_type();
|
||||
if file_type.is_symlink() {
|
||||
realpath.push(fs::read_link(&mi.source)?);
|
||||
}
|
||||
let sysname = path::Path::new(&realpath);
|
||||
if let Some(sysname) = sysname.file_name() {
|
||||
enumerator.match_sysname(sysname)?;
|
||||
} else {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
for device in enumerator.scan_devices()? {
|
||||
if let Some(uuid) = device.property_value("ID_FS_UUID") {
|
||||
if let Some(uuid) = uuid.to_str() {
|
||||
return Ok(uuid.into());
|
||||
} else {
|
||||
return Err(Error::InvalidUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Err(Error::NotMounted)
|
||||
}
|
||||
Reference in New Issue
Block a user