src/main.rs
/*
* Copyright 2017 Trevor Bentley
*
* Author: Trevor Bentley
* Contact: trevor@trevorbentley.com
* Source: https://github.com/mrmekon/circadian
*
* This file is part of Circadian.
*
* Circadian is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Circadian is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Circadian. If not, see <http://www.gnu.org/licenses/>.
*/
extern crate regex;
use std::collections::HashSet;
use std::io::BufRead;
use std::os::linux::fs::MetadataExt;
use regex::Regex;
extern crate glob;
use glob::glob;
extern crate clap;
use clap::Parser;
extern crate ini;
use ini::Ini;
extern crate nix;
use nix::sys::signal;
extern crate time;
use time::macros::*;
extern crate users;
use users::get_user_by_name;
use std::io::Write;
use std::path::PathBuf;
use std::process::Stdio;
use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::os::unix::process::CommandExt;
pub static VERBOSITY: AtomicUsize = AtomicUsize::new(0);
pub const MAX_VERBOSITY: usize = 4;
#[allow(unused_macros)]
macro_rules! println_vb1 {
() => { if VERBOSITY.load(Ordering::SeqCst) > 0 { println!() } };
($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::SeqCst) > 0 { println!($($arg)*); } }};
}
#[allow(unused_macros)]
macro_rules! println_vb2 {
() => { if VERBOSITY.load(Ordering::SeqCst) > 1 { println!() } };
($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::SeqCst) > 1 { println!($($arg)*); } }};
}
#[allow(unused_macros)]
macro_rules! println_vb3 {
() => { if VERBOSITY.load(Ordering::SeqCst) > 2 { println!() } };
($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::SeqCst) > 2 { println!($($arg)*); } }};
}
#[allow(unused_macros)]
macro_rules! println_vb4 {
() => { if VERBOSITY.load(Ordering::SeqCst) > 3 { println!() } };
($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::SeqCst) > 3 { println!($($arg)*); } }};
}
pub struct CircadianError(String);
impl std::fmt::Display for CircadianError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::fmt::Debug for CircadianError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for CircadianError {
fn description(&self) -> &str {
self.0.as_str()
}
fn cause(&self) -> Option<&dyn std::error::Error> {
None
}
}
impl <'a> From<&'a str> for CircadianError {
fn from(error: &str) -> Self {
CircadianError(error.to_owned())
}
}
impl From<std::io::Error> for CircadianError {
fn from(error: std::io::Error) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<regex::Error> for CircadianError {
fn from(error: regex::Error) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<std::num::ParseIntError> for CircadianError {
fn from(error: std::num::ParseIntError) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<std::string::FromUtf8Error> for CircadianError {
fn from(error: std::string::FromUtf8Error) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<glob::PatternError> for CircadianError {
fn from(error: glob::PatternError) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<ini::Error> for CircadianError {
fn from(error: ini::Error) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<nix::Error> for CircadianError {
fn from(error: nix::Error) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<time::error::Parse> for CircadianError {
fn from(error: time::error::Parse) -> Self {
CircadianError(error.to_string().to_owned())
}
}
impl From<time::error::ComponentRange> for CircadianError {
fn from(error: time::error::ComponentRange) -> Self {
CircadianError(error.to_string().to_owned())
}
}
type IdleResult = Result<u32, CircadianError>;
type ThreshResult = Result<bool, CircadianError>;
type ExistResult = Result<bool, CircadianError>;
#[allow(dead_code)]
enum NetConnection {
SSH,
SMB,
NFS,
}
#[allow(dead_code)]
enum CpuHistory {
Min1,
Min5,
Min15
}
#[derive(Clone)]
struct AutoWakeEpoch {
epoch: i64,
is_utc: bool,
}
#[derive(Debug)]
struct IdleResponse {
w_idle: IdleResult,
w_enabled: bool,
xssstate_idle: IdleResult,
xssstate_enabled: bool,
xprintidle_idle: IdleResult,
xprintidle_enabled: bool,
wake_remain: u32,
tty_idle: u32,
tty_enabled: bool,
x11_idle: u32,
x11_enabled: bool,
min_idle: u32,
idle_target: u64,
idle_remain: u64,
is_idle: bool,
}
impl std::fmt::Display for IdleResponse {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let result_map = vec![
(self.w_idle.as_ref(), self.w_enabled, "w"),
(self.xssstate_idle.as_ref(), self.xssstate_enabled, "xssstate"),
(self.xprintidle_idle.as_ref(), self.xprintidle_enabled, "xprintidle"),
];
for (var,enabled,name) in result_map {
let s = match var {
Ok(x) => x.to_string(),
Err(e) => e.to_string(),
};
let enabled = match enabled {
true => "*",
_ => "",
};
let name = format!("{}{}", name, enabled);
let _ = write!(f, "{:<16}: {}\n", name, s);
}
let _ = write!(f, "{:<16}: {}\n", "Wake block", self.wake_remain);
let int_map = vec![
(self.tty_idle, self.tty_enabled, "TTY (combined)"),
(self.x11_idle, self.x11_enabled, "X11 (combined)"),
];
for (var,enabled,name) in int_map {
let enabled = match enabled {
true => "*",
_ => "",
};
let name = format!("{}{}", name, enabled);
let _ = write!(f, "{:<16}: {}\n", name, var);
}
let _ = write!(f, "{:<16}: {}\n", "Idle (min)", self.min_idle);
let _ = write!(f, "{:<16}: {}\n", "Idle target", self.idle_target);
let _ = write!(f, "{:<16}: {}\n", "Until idle", self.idle_remain);
let _ = write!(f, "{:<16}: {}\n", "IDLE?", self.is_idle);
Ok(())
}
}
#[derive(Debug)]
struct NonIdleResponse {
cpu_load: ThreshResult,
cpu_load_enabled: bool,
ssh: ExistResult,
ssh_enabled: bool,
smb: ExistResult,
smb_enabled: bool,
nfs: ExistResult,
nfs_enabled: bool,
audio: ExistResult,
audio_enabled: bool,
procs: ExistResult,
procs_enabled: bool,
is_blocked: bool,
}
impl std::fmt::Display for NonIdleResponse {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let result_map = vec![
(self.cpu_load.as_ref(), self.cpu_load_enabled, "CPU load"),
(self.ssh.as_ref(), self.ssh_enabled, "SSH"),
(self.smb.as_ref(), self.smb_enabled, "SMB"),
(self.nfs.as_ref(), self.nfs_enabled, "NFS"),
(self.audio.as_ref(), self.audio_enabled, "Audio"),
(self.procs.as_ref(), self.procs_enabled, "Processes"),
];
for (var,enabled,name) in result_map {
let s = match var {
Ok(x) => x.to_string(),
Err(e) => e.to_string(),
};
let enabled = match enabled {
true => "*",
_ => "",
};
let name = format!("{}{}", name, enabled);
let _ = write!(f, "{:<16}: {}\n", name, s);
}
let _ = write!(f, "{:<16}: {}\n", "BLOCKED?", self.is_blocked);
Ok(())
}
}
static SIGUSR_SIGNALED: AtomicBool = AtomicBool::new(false);
/// Set global flag when SIGUSR1 signal is received
extern fn sigusr1_handler(_: i32) {
SIGUSR_SIGNALED.store(true, Ordering::SeqCst);
}
/// Register SIGUSR1 signal handler
fn register_sigusr1() -> Result<signal::SigAction, CircadianError> {
let sig_handler = signal::SigHandler::Handler(sigusr1_handler);
let sig_action = signal::SigAction::new(sig_handler,
signal::SaFlags::empty(),
signal::SigSet::empty());
unsafe {
Ok(signal::sigaction(signal::SIGUSR1, &sig_action)?)
}
}
fn command_exists(cmd: &str) -> bool {
match Command::new(cmd)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status() {
Ok(_) => true,
Err(_) => false,
}
}
/// Parse idle time strings from 'w' command into seconds
fn parse_w_time(time_str: &str) -> Result<u32, CircadianError> {
let mut secs: u32 = 0;
let mut mins: u32 = 0;
let mut hours:u32 = 0;
let re_sec = Regex::new(r"^\d+.\d+s$")?;
let re_min = Regex::new(r"^\d+:\d+$")?;
let re_hour = Regex::new(r"^\d+:\d+m$")?;
if re_sec.is_match(time_str) {
let time_str: &str = time_str.trim_matches('s');
let parts: Vec<u32> = time_str.split(".")
.map(|s| str::parse::<u32>(s).unwrap_or(0))
.collect();
secs = *parts.get(0).unwrap_or(&0);
}
else if re_min.is_match(time_str) {
let parts: Vec<u32> = time_str.split(":")
.map(|s| str::parse::<u32>(s).unwrap_or(0))
.collect();
mins = *parts.get(0).unwrap_or(&0);
secs = *parts.get(1).unwrap_or(&0);
}
else if re_hour.is_match(time_str) {
let time_str: &str = time_str.trim_matches('m');
let parts: Vec<u32> = time_str.split(":")
.map(|s| str::parse::<u32>(s).unwrap_or(0))
.collect();
hours = *parts.get(0).unwrap_or(&0);
mins = *parts.get(1).unwrap_or(&0);
}
else {
return Err(CircadianError("Invalid idle format".to_string()));
}
Ok((hours*60*60) + (mins*60) + secs)
}
// count number of fields in 'w' output
//
// This is a stupid requirement because some linux distros build w
// with the 'FROM' field enabled by default and others with it
// disabled. 'w' has a command-line option to *toggle* the field, but
// no to forcibly enable/disable it.
fn count_w_fields() -> Result<usize, CircadianError> {
let w_stdout = Stdio::piped();
let s_stdout = Stdio::piped();
let mut w_output = Command::new("w")
.arg("-us")
.stdout(w_stdout).spawn()?;
let _ = w_output.wait()?;
let w_stdout = w_output.stdout
.ok_or(CircadianError("w command has no output".into()))?;
// print just the second row, the header
let mut sed_output = Command::new("sed")
.arg("-n")
.arg("2p")
.stdin(w_stdout)
.stdout(s_stdout)
.spawn()?;
let _ = sed_output.wait()?;
let s_stdout = sed_output.stdout
.ok_or(CircadianError("w/sed command has no output".into()))?;
let awk_output = Command::new("awk")
.arg("{print NF}")
.stdin(s_stdout)
.output()?;
let num_fields: usize = String::from_utf8(awk_output.stdout)
.unwrap_or(String::new())
.trim()
.parse::<usize>()?;
Ok(num_fields)
}
// Returns tuple containing argument string to provide to `w` to have
// the FROM field included, and the 0-indexed offset of the field.
fn w_from_args() -> Result<(String, usize), CircadianError> {
// Ask for a fake user to get just the header and check if the
// FROM field is enabled.
let w_output = Command::new("w")
.arg("-us")
.arg("CIRCADIAN_FAKEUSER")
.output()?;
let w_fields: Vec<String> = w_output.stdout
.lines()
.nth(1) // second line is the header
.ok_or(CircadianError("w command has no output".into()))??
.split_whitespace().map(|x| x.to_owned()).collect();
let from_header = String::from("FROM");
let (hargs, args) = match w_fields.contains(&from_header) {
true => ("-hus".to_string(), "-us".to_string()),
false => ("-husf".to_string(), "-usf".to_string()),
};
// Do it again with FROM field on and find its index.
let w_output = Command::new("w")
.arg(&args)
.arg("CIRCADIAN_FAKEUSER")
.output()?;
let w_fields: Vec<String> = w_output.stdout
.lines()
.nth(1)
.ok_or(CircadianError("w command has no output".into()))??
.split_whitespace().map(|x| x.to_owned()).collect();
let idx = w_fields.iter()
.position(|x| x == &from_header)
.ok_or(CircadianError("w command arguments invalid".into()))?;
Ok((hargs, idx))
}
fn xauthority_from_cmdline(display: &str) -> Result<String, CircadianError> {
// Look for PIDs of processes with a variety of X11-related names.
let pgrep_out = Command::new("pgrep")
.arg("(X11|Xorg|xinit|Xwayland|Xephyr)")
.output()?;
let pids: Vec<String> = String::from_utf8(pgrep_out.stdout)
.unwrap_or(String::new())
.split("\n")
.map(|s| s.trim().to_owned())
.filter(|s| s.len() > 0)
.collect();
let display = display.to_owned();
// Read the command-line arguments out of procfs for all of the
// matching PIDs.
for pid in &pids {
let cmdline_path = PathBuf::from(format!("/proc/{}/cmdline", pid));
if !cmdline_path.exists() {
continue;
}
let raw_args: String = String::from_utf8(std::fs::read(&cmdline_path)?)?;
let split_args: Vec<String> = raw_args.split("\0").map(|s| s.to_owned()).collect();
// Look for processes that have the display name (":0") as an
// argument, and also have a "-auth" argument. The parameter
// after "-auth" should be an xauth file.
let auth_arg = String::from("-auth");
if split_args.contains(&auth_arg) && split_args.contains(&display) {
// Grab the next argument after "-auth"
let split_args: Vec<String> = split_args.iter().rev().take_while(|s| *s != "-auth").map(|s| s.to_owned()).collect();
let auth_arg: &str = split_args.iter().map(|x| x.as_str()).rev().next().unwrap_or("");
let auth_path = PathBuf::from(auth_arg);
// Ensure the file actually exists
if auth_path.exists() {
// return the first match
println_vb4!(" - xauth from cmdline: {}", auth_arg);
return Ok(auth_arg.to_string());
}
}
}
Err(CircadianError("No auth file specified on command line.".into()))
}
fn xauthority_for_uid(uid: u32, display: &str) -> String {
// Default to whatever is currently in the XAUTHORITY environment
// variable, if anything.
let default = std::env::var("XAUTHORITY").unwrap_or("".into());
// If root user, try to read the xauth file out of the
// command-line arguments. This one takes highest priority, if it
// exists.
if uid == 0 {
if let Ok(xauth) = xauthority_from_cmdline(display) {
return xauth;
}
}
// getent displays the row from /etc/passwd for a specific user
let getent_stdout = Stdio::piped();
let mut getent_output = match Command::new("getent")
.arg("passwd")
.arg(uid.to_string())
.stdout(getent_stdout).spawn() {
Ok(w) => w,
_ => return default,
};
let _ = match getent_output.wait() {
Ok(_) => {},
_ => return default,
};
let getent_stdout = match getent_output.stdout {
Some(w) => w,
_ => return default,
};
// the 6th colon-separated field is the user's home directory
let cut_output = match Command::new("cut")
.arg("-d:")
.arg("-f6")
.stdin(getent_stdout)
.output() {
Ok(o) => o,
_ => return default,
};
// look for ~/.Xauthority for the specified user
let homedir = String::from_utf8(cut_output.stdout)
.unwrap_or(default.clone())
.trim().to_owned();
let mut homedir = PathBuf::from(homedir);
homedir.push(".Xauthority");
// return the path if it exists, otherwise just use whatever the
// current XAUTHORITY environment variable is set to.
match homedir.exists() {
true => homedir.to_str().unwrap_or(&default).to_owned(),
_ => default,
}
}
/// Call 'w' command and return minimum idle time
fn idle_w() -> IdleResult {
let num_fields = count_w_fields()?;
let w_stdout = Stdio::piped();
let mut w_output = Command::new("w")
.arg("-hus")
.stdout(w_stdout).spawn()?;
let _ = w_output.wait()?;
let w_stdout = w_output.stdout
.ok_or(CircadianError("w command has no output".into()))?;
// idle field is the second to last
let awk_output = Command::new("awk")
.arg(format!("{{print ${}}}", num_fields - 1))
.stdin(w_stdout)
.output()?;
let idle_times: Vec<u32> = String::from_utf8(awk_output.stdout)
.unwrap_or(String::new())
.split("\n")
.filter(|t| t.len() > 0)
.map(|t| parse_w_time(t))
.filter_map(|t| t.ok())
.collect();
Ok(idle_times.iter().cloned().fold(std::u32::MAX, std::cmp::min))
}
/// Call idle command for each X display
///
/// 'cmd' should be a unix command that, given args 'args', prints the idle
/// time in milliseconds. It will be run with the DISPLAY env variable set
/// and with the uid of the user that owns the DISPLAY, for every running
/// X display. The minimum of all found idle times is returned.
fn idle_fn(cmd: &str, args: Vec<&str>) -> IdleResult {
let mut display_mins: Vec<u32> = Vec::<u32>::new();
let (w_args, from_idx) = w_from_args()?;
println_vb4!("cmd: {} / w args: '{}' / w field: {}", cmd, w_args, from_idx);
for device in glob("/tmp/.X11-unix/X*")? {
println_vb4!(" - socket: {:?}", device);
let device: String = match device {
Ok(p) => p.to_str().unwrap_or("0").to_owned(),
_ => "0".to_owned(),
};
let display = format!(":{}", device.chars().rev().next().unwrap_or('0'));
println_vb4!(" - display: {}", display);
let mut output = Command::new("w")
.arg(&w_args)
.stdout(Stdio::piped()).spawn()?;
let _ = output.wait()?;
let w_stdout = output.stdout
.ok_or(CircadianError("w command has no output".into()))?;
let awk_arg = format!("{{if (${} ~ /^{}/) print $1}}", from_idx + 1, display);
let output = Command::new("awk")
.arg(awk_arg)
.stdin(w_stdout)
.output()?;
let user_str = String::from_utf8(output.stdout)
.unwrap_or(String::new());
println_vb4!(" - awk users ({}): {}", user_str.len(), user_str.replace("\n", " / "));
// Get a list of all system users with open sessions to this
// X11 display, and de-duplicate by storing in a set. There
// should be one per logged in user if a session manager is in
// use, plus one for each terminal the user has open.
let user_list: HashSet<&str> = user_str.split("\n")
.map(|x| x.trim())
.filter(|x| x.len() > 0)
.collect();
// Convert all of the user names to UIDs.
let mut user_list: HashSet<u32> = user_list.iter()
.filter_map(|x| match x.trim() {
user if user.len() > 0 => {
match Command::new("id").arg("-u").arg(user).output() {
Ok(output) => {
let mut uid = String::from_utf8(output.stdout)
.unwrap_or(String::new());
uid.pop();
let uid = uid.parse::<u32>().unwrap_or(0);
Some(uid)
},
Err(_) => {
None
}
}
},
_ => {
None
}
})
.collect();
// Insert the UID of the socket owner, too. This covers X
// servers spawned without session managers, i.e. those
// spawned with 'startx' or Xephyr.
let owner_uid = std::fs::metadata(&device)?.st_uid();
user_list.insert(owner_uid);
// Always give it a try as root, too, since root can read
// xauth files from anywhere.
user_list.insert(0);
println_vb4!(" - socket owner: {}", owner_uid);
for uid in user_list {
println_vb4!(" - UID: {}", uid);
let xauth = xauthority_for_uid(uid, &display);
println_vb4!(" - xauthority: {}", xauth);
// allow this command to fail, in case there are several X
// servers running.
match Command::new(cmd)
.args(&args)
.uid(uid)
.env("DISPLAY", &display)
.env("XAUTHORITY", &xauth)
.output() {
Ok(output) => {
let mut idle_str = String::from_utf8(output.stdout)
.unwrap_or(String::new());
idle_str.pop();
let idle = idle_str.parse::<u32>().unwrap_or(std::u32::MAX)/1000;
println_vb4!(" - idle: {}", idle);
display_mins.push(idle);
},
Err(e) => {
println!("WARNING: {} failed for socket {} with error: {}", cmd, device, e);
},
}
}
}
match display_mins.len() {
0 => Err(CircadianError("No displays found.".to_string())),
_ => Ok(display_mins.iter().fold(std::u32::MAX, |acc, x| std::cmp::min(acc,*x)))
}
}
/// Call 'xprintidle' command and return idle time
fn idle_xprintidle() -> IdleResult {
idle_fn("xprintidle", vec![])
}
/// Call 'xssstate' command and return idle time
fn idle_xssstate() -> IdleResult {
idle_fn("xssstate", vec!["-i"])
}
/// Compare whether 'uptime' 5-min CPU usage compares
/// to the given thresh with the given cmp function.
///
/// ex: thresh_cpu(CpuHistory::Min1, 0.1, std::cmp::PartialOrd::lt) returns true
/// if the 5-min CPU usage is less than 0.1 for the past minute
///
fn thresh_cpu<C>(history: CpuHistory, thresh: f64, cmp: C) -> ThreshResult
where C: Fn(&f64, &f64) -> bool {
let output = Command::new("uptime")
.output()?;
let uptime_str = String::from_utf8(output.stdout)
.unwrap_or(String::new());
let columns: Vec<&str> = uptime_str.split(" ").collect();
let cpu_usages: Vec<f64> = columns.iter()
.rev().take(3).map(|x| *x).collect::<Vec<&str>>().iter()
.rev()
.map(|x| *x)
.filter(|x| x.len() > 0)
.map(|x| str::parse::<f64>(&x[0..x.len()-1].replace(",",".")).unwrap_or(0.0))
.collect::<Vec<f64>>();
let idle: Vec<bool> = cpu_usages.iter()
.map(|x| cmp(x, &thresh))
.collect();
// idle is bools of [1min, 5min, 15min] CPU usage
let idx = match history {
CpuHistory::Min1 => 0,
CpuHistory::Min5 => 1,
CpuHistory::Min15 => 2,
};
// false == below threshold, true == above
Ok(!*idle.get(idx).unwrap_or(&false))
}
/// Determine whether a process (by name regex) is running.
fn exist_process(prc: &str) -> ExistResult {
let output = Command::new("pgrep")
.arg("-c")
.arg(prc)
.output()?;
let output = &output.stdout[0..output.stdout.len()-1];
let count: u32 = String::from_utf8(output.to_vec())
.unwrap_or(String::new()).parse::<u32>()?;
Ok(count > 0)
}
/// Determine whether the given type of network connection is established.
fn exist_net_connection(conn: NetConnection) -> ExistResult {
let mut output = Command::new("netstat")
.arg("-tnpa")
.stderr(Stdio::null())
.stdout(Stdio::piped()).spawn()?;
let _ = output.wait()?;
let stdout = output.stdout
.ok_or(CircadianError("netstat command has no output".to_string()))?;
let mut output = Command::new("grep")
.arg("ESTABLISHED")
.stdin(stdout)
.stdout(Stdio::piped()).spawn()?;
let _ = output.wait()?;
let stdout = output.stdout
.ok_or(CircadianError("netstat command has no connections".to_string()))?;
let pattern = match conn {
NetConnection::SSH => "[0-9]+/ssh[d]*",
NetConnection::SMB => "[0-9]+/smb[d]*",
NetConnection::NFS => "[0-9]+:2049\\s",
};
let output = Command::new("grep")
.arg("-E")
.arg(pattern)
.stdin(stdout)
.output()?;
let output = String::from_utf8(output.stdout)
.unwrap_or(String::new());
let connections: Vec<&str> = output
.split("\n")
.filter(|l| l.len() > 0)
.collect();
Ok(connections.len() > 0)
}
/// Determine whether audio is actively playing on any ALSA interface.
fn exist_audio_alsa() -> ExistResult {
let mut count = 0;
for device in glob("/proc/asound/card*/pcm*/sub*/status")? {
if let Ok(path) = device {
let mut cat_output = Command::new("cat")
.arg(path)
.stderr(Stdio::null())
.stdout(Stdio::piped()).spawn()?;
let _ = cat_output.wait()?;
let stdout = cat_output.stdout
.ok_or(CircadianError("cat /proc/asound/* failed".to_string()))?;
let output = Command::new("grep")
.arg("state:")
.stdin(stdout)
.output()?;
let output_str = String::from_utf8(output.stdout)?;
let lines: Vec<&str> = output_str.split("\n")
.filter(|l| l.len() > 0)
.collect();
count += lines.len();
}
}
Ok(count > 0)
}
/// Determine whether audio is actively playing on any Pulseaudio interface.
fn exist_audio_pulseaudio() -> ExistResult {
let users_output = Command::new("users")
.stderr(Stdio::null())
.output();
let users_stdout = users_output
.map_err(| _ | CircadianError("users failed".to_string()));
let users_output_str = String::from_utf8(users_stdout?.stdout)?;
let active_users: HashSet<&str> = users_output_str.split(" ")
.filter(|l| l.len() > 0)
.collect();
let mut count = 0;
for active_user in active_users {
match get_user_by_name(&active_user.trim()) {
Some(x) => {
let active_user_id = x.uid();
let mut pactl_output = Command::new("pactl").uid(active_user_id)
.env("XDG_RUNTIME_DIR", format!("/run/user/{}", active_user_id))
.args(["list", "sinks", "short"])
.stderr(Stdio::null())
.stdout(Stdio::piped()).spawn()?;
let _ = pactl_output.wait()?;
let stdout = pactl_output.stdout
.ok_or(CircadianError("pactl failed".to_string()))?;
let output = Command::new("grep")
.arg("RUNNING") // Does not includes IDLE == Paused audio
.stdin(stdout)
.output()?;
let output_str = String::from_utf8(output.stdout)?;
let lines: Vec<&str> = output_str.split("\n")
.filter(|l| l.len() > 0)
.collect();
count += lines.len();
},
None => continue,
}
}
Ok(count > 0)
}
fn exist_audio() -> ExistResult {
let audio_alsa = exist_audio_alsa();
let audio_pulseaudio = exist_audio_pulseaudio();
Ok(*audio_alsa.as_ref().unwrap_or(&false) || *audio_pulseaudio.as_ref().unwrap_or(&false))
}
#[derive(Parser)]
#[command(author, version, about, long_about)]
#[command(help_template = "\
{name} v{version}, {author-with-newline}
{about-with-newline}
{usage-heading} {usage}
{all-args}{after-help}
")]
struct CircadianLaunchOptions {
#[arg(short = 'f', long = "config", value_name = "FILE", default_value_t = String::from("/etc/circadian.conf"))]
config_file: String,
#[arg(short, long)]
test: bool,
#[arg(short, long = "verbose", action = clap::ArgAction::Count)]
verbosity: u8,
}
#[derive(Default,Debug)]
struct CircadianConfig {
verbosity: usize,
idle_time: u64,
auto_wake: Option<String>,
on_idle: Option<String>,
on_wake: Option<String>,
tty_input: bool,
x11_input: bool,
ssh_block: bool,
smb_block: bool,
nfs_block: bool,
audio_block: bool,
max_cpu_load: Option<f64>,
process_block: Vec<String>,
}
fn read_config(file_path: &str) -> Result<CircadianConfig, CircadianError> {
println!("Reading config from file: {}", file_path);
let i = Ini::load_from_file(file_path)?;
let mut config: CircadianConfig = Default::default();
if let Some(section) = i.section(Some("settings".to_owned())) {
let verbosity: usize = section.get("verbosity")
.and_then(|x| match x {
x if x.len() > 0 => { x.parse::<usize>().ok() },
_ => None,
})
.unwrap_or(0);
let verbosity = std::cmp::min(verbosity, MAX_VERBOSITY);
VERBOSITY.store(verbosity, Ordering::SeqCst);
config.verbosity = verbosity;
}
if let Some(section) = i.section(Some("actions".to_owned())) {
config.idle_time = section.get("idle_time")
.map_or(0, |x| if x.len() > 0 {
let (body,suffix) = x.split_at(x.len()-1);
let num: u64 = match suffix {
"m" => body.parse::<u64>().unwrap_or(0) * 60,
"h" => body.parse::<u64>().unwrap_or(0) * 60 * 60,
_ => x.parse::<u64>().unwrap_or(0),
};
num
} else {0});
config.auto_wake = section.get("auto_wake")
.and_then(|x| if x.len() > 0 {Some(x.to_owned())} else {None});
config.on_idle = section.get("on_idle")
.and_then(|x| if x.len() > 0 {Some(x.to_owned())} else {None});
config.on_wake = section.get("on_wake")
.and_then(|x| if x.len() > 0 {Some(x.to_owned())} else {None});
}
fn read_bool(s: &ini::Properties,
key: &str) -> bool {
match s.get(key).unwrap_or(&"no".to_string()).to_lowercase().as_str() {
"yes" | "true" | "1" => true,
_ => false,
}
}
if let Some(section) = i.section(Some("heuristics".to_owned())) {
config.tty_input = read_bool(section, "tty_input");
config.x11_input = read_bool(section, "x11_input");
config.ssh_block = read_bool(section, "ssh_block");
config.smb_block = read_bool(section, "smb_block");
config.nfs_block = read_bool(section, "nfs_block");
config.audio_block = read_bool(section, "audio_block");
config.max_cpu_load = section.get("max_cpu_load")
.and_then(|x| if x.len() > 0
{Some(x.parse::<f64>().unwrap_or(999.0))} else {None});
if let Some(proc_str) = section.get("process_block") {
let proc_list = proc_str.split(",");
config.process_block = proc_list
.map(|x| x.trim().to_owned()).collect();
}
}
Ok(config)
}
fn test_idle(config: &CircadianConfig, start: i64) -> IdleResponse {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let tty = idle_w();
let xssstate = idle_xssstate();
let xprintidle = idle_xprintidle();
let tty_idle = *tty.as_ref().unwrap_or(&std::u32::MAX);
let x11_idle = std::cmp::min(*xssstate.as_ref().unwrap_or(&std::u32::MAX),
*xprintidle.as_ref().unwrap_or(&std::u32::MAX));
let wake_remain = std::cmp::max(0, start + (config.idle_time as i64) - now) as u32;
let min_idle: u32 = match (config.tty_input, config.x11_input) {
(true,true) => std::cmp::min(tty_idle, x11_idle) as u32,
(true,false) => tty_idle as u32,
(false,_) => x11_idle as u32,
};
let idle_remain: u64 =
std::cmp::max(config.idle_time as i64 - min_idle as i64, 0) as u64;
IdleResponse {
w_idle: tty,
w_enabled: config.tty_input,
xssstate_idle: xssstate,
xssstate_enabled: config.x11_input,
xprintidle_idle: xprintidle,
xprintidle_enabled: config.x11_input,
wake_remain: wake_remain,
tty_idle: tty_idle,
tty_enabled: config.tty_input,
x11_idle: x11_idle,
x11_enabled: config.x11_input,
min_idle: min_idle,
idle_target: config.idle_time,
idle_remain: idle_remain,
is_idle: idle_remain == 0 && wake_remain == 0,
}
}
fn test_nonidle(config: &CircadianConfig) -> NonIdleResponse {
let cpu_load = thresh_cpu(CpuHistory::Min1,
config.max_cpu_load.unwrap_or(999.0),
std::cmp::PartialOrd::lt);
let cpu_load_enabled = config.max_cpu_load.is_some() &&
config.max_cpu_load.unwrap() < 999.0;
let ssh = exist_net_connection(NetConnection::SSH);
let ssh_enabled = config.ssh_block;
let smb = exist_net_connection(NetConnection::SMB);
let smb_enabled = config.smb_block;
let nfs = exist_net_connection(NetConnection::NFS);
let nfs_enabled = config.nfs_block;
let audio = exist_audio();
let audio_enabled = config.audio_block;
let procs = config.process_block.iter()
// Run 'exist_process' on each process string
.map(|p| exist_process(p))
// Flatten into a single result with a Vec of bools
.collect::<Result<Vec<bool>, CircadianError>>()
// Flatten Vec of bools into a single bool
.map(|x| x.iter().fold(false, |acc,p| acc || *p));
let procs_enabled = config.process_block.len() > 0;
let blocked = (cpu_load_enabled && *cpu_load.as_ref().unwrap_or(&true)) ||
(ssh_enabled && *ssh.as_ref().unwrap_or(&true)) ||
(smb_enabled && *smb.as_ref().unwrap_or(&true)) ||
(nfs_enabled && *nfs.as_ref().unwrap_or(&true)) ||
(audio_enabled && *audio.as_ref().unwrap_or(&true)) ||
(procs_enabled && *procs.as_ref().unwrap_or(&true));
NonIdleResponse {
cpu_load: cpu_load,
cpu_load_enabled: cpu_load_enabled,
ssh: ssh,
ssh_enabled: ssh_enabled,
smb: smb,
smb_enabled: smb_enabled,
nfs: nfs,
nfs_enabled: nfs_enabled,
audio: audio,
audio_enabled: audio_enabled,
procs: procs,
procs_enabled: procs_enabled,
is_blocked: blocked,
}
}
fn is_rtc_utc() -> Result<bool, CircadianError> {
fn get_rtc_time() -> Result<String, CircadianError> {
let output = Command::new("cat")
.arg("/sys/class/rtc/rtc0/time")
.output()?;
let t = String::from_utf8(output.stdout)?;
Ok(t.split(":").take(2).collect::<Vec<&str>>().join(":"))
}
let utc_tm = time::OffsetDateTime::now_utc().time();
let utc_time = format!("{:02}:{:02}", utc_tm.hour(), utc_tm.minute());
let rtc_time = get_rtc_time()?;
let is_utc = utc_time == rtc_time;
let is_synced = utc_time.split(":").nth(1) == rtc_time.split(":").nth(1);
match is_synced {
true => Ok(is_utc),
_ => Err("RTC clock does not match OS clock. Auto-wake disabled.".into()),
}
}
fn auto_wake_to_epoch(auto_wake: &str) -> Result<AutoWakeEpoch, CircadianError> {
let _ = is_rtc_utc()?; // just to detect RTC sync errors
let format = format_description!("[hour]:[minute]");
let auto_wake_tm = time::Time::parse(auto_wake, &format)?;
let now_local = match time::OffsetDateTime::now_local() {
Ok(l) => l,
_ => time::OffsetDateTime::now_utc(),
};
let target_time_local = now_local.clone();
let target_time_local = target_time_local.replace_hour(auto_wake_tm.hour())?;
let target_time_local = target_time_local.replace_minute(auto_wake_tm.minute())?;
let target_time_local = target_time_local.replace_second(0)?;
let target_time_local = match target_time_local < now_local {
true => target_time_local + time::Duration::days(1),
false => target_time_local,
};
// UNIX timestamps are defined as being in UTC, and Linux's RTC is
// defined as taking UTC timestamps.
Ok(AutoWakeEpoch {
epoch: target_time_local.unix_timestamp(),
is_utc: true
})
}
fn set_rtc_wakealarm(timestamp: i64) -> Result<(), CircadianError> {
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open("/sys/class/rtc/rtc0/wakealarm")?;
f.write_all("0\n".as_bytes())?;
}
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open("/sys/class/rtc/rtc0/wakealarm")?;
f.write_all(format!("{}\n", timestamp).as_bytes())?;
}
Ok(())
}
fn set_auto_wake(auto_wake: Option<&String>) -> Result<AutoWakeEpoch, CircadianError> {
if auto_wake.is_none() {
return Err("Auto-wake not enabled.".into());
}
let time = auto_wake.unwrap();
let epoch = auto_wake_to_epoch(time)?;
let _ = set_rtc_wakealarm(epoch.epoch)?;
println!("Auto-wake scheduled for {} ({})", time, epoch.epoch);
Ok(epoch)
}
fn reschedule_auto_wake(auto_wake: Option<&String>, current_epoch: Option<AutoWakeEpoch>) -> Option<AutoWakeEpoch> {
let mut new_rtc: Option<AutoWakeEpoch> = current_epoch.clone();
if auto_wake.is_none() || current_epoch.is_none() {
return None;
}
let epoch = current_epoch.unwrap();
let now = match epoch.is_utc {
true => time::OffsetDateTime::now_utc().unix_timestamp(),
false => match time::OffsetDateTime::now_local() {
Ok(l) => l.unix_timestamp(),
_ => time::OffsetDateTime::now_utc().unix_timestamp(),
}
};
if now >= epoch.epoch {
new_rtc = match set_auto_wake(auto_wake) {
Ok(epoch) => Some(epoch),
Err(e) => {println!("Error recheduling auto-wake timer: {}. Disabled.", e); None},
};
}
new_rtc
}
#[allow(dead_code)]
fn test() {
println!("Sec: {:?}", parse_w_time("10.45s"));
println!("Sec: {:?}", parse_w_time("1:11"));
println!("Sec: {:?}", parse_w_time("0:10m"));
println!("w min: {:?}", idle_w());
println!("xssstate min: {:?}", idle_xssstate());
println!("xprintidle min: {:?}", idle_xprintidle());
println!("cpu: {:?}", thresh_cpu(CpuHistory::Min5, 0.3, std::cmp::PartialOrd::lt));
println!("ssh: {:?}", exist_net_connection(NetConnection::SSH));
println!("smb: {:?}", exist_net_connection(NetConnection::SMB));
println!("nfs: {:?}", exist_net_connection(NetConnection::NFS));
println!("iotop: {:?}", exist_process("^iotop$"));
println!("audio: {:?}", exist_audio());
}
fn main() {
let launch_opts = CircadianLaunchOptions::parse();
let config = read_config(&launch_opts.config_file)
.unwrap_or_else(|x| {
println!("{}", x);
println!("Could not open config file. Exiting.");
std::process::exit(1);
});
println!("Circadian launching.");
println!("{:?}", config);
// override config verbosity from command-line
if launch_opts.verbosity as usize > config.verbosity {
VERBOSITY.store(launch_opts.verbosity as usize, Ordering::SeqCst);
}
// override user locale to make all command outputs uniform (e.g. when parsing column headers or dates/times)
std::env::set_var("LC_ALL", "C");
if launch_opts.test {
println!("Got --test: running idle test and exiting.");
let start = time::OffsetDateTime::now_utc().unix_timestamp();
let idle = test_idle(&config, start);
let tests = test_nonidle(&config);
println!("Idle Detection Summary:\n{}{}", idle, tests);
std::process::exit(0);
}
if !config.tty_input && !config.x11_input {
println!("tty_input or x11_input must be enabled. Exiting.");
std::process::exit(1);
}
if config.tty_input && !command_exists("w") {
println!("'w' command required by tty_input failed. Exiting.");
std::process::exit(1);
}
if config.x11_input &&
!command_exists("xssstate") &&
!command_exists("xprintidle") {
println!("Both 'xssstate' and 'xprintidle' commands required by x11_input failed. Exiting.");
std::process::exit(1);
}
if config.max_cpu_load.is_some() &&
thresh_cpu(CpuHistory::Min1, 0.0, std::cmp::PartialOrd::lt).is_err() {
println!("'uptime' command required by max_cpu_load failed. Exiting.");
std::process::exit(1);
}
if (config.ssh_block || config.smb_block || config.nfs_block) &&
exist_net_connection(NetConnection::SSH).is_err() {
println!("'netstat' command required by ssh/smb/nfs_block failed. Exiting.");
std::process::exit(1);
}
if config.audio_block && (exist_audio_alsa().is_err() && exist_audio_pulseaudio().is_err()) {
println!("'/proc/asound/' and pactl required by audio_block is unreadable. Exiting.");
std::process::exit(1);
}
if config.process_block.len() > 0 && exist_process("").is_err() {
println!("'pgrep' required by process_block failed. Exiting.");
std::process::exit(1);
}
if config.idle_time == 0 {
println!("Idle time disabled. Nothing to do. Exiting.");
std::process::exit(1);
}
let mut current_rtc: Option<AutoWakeEpoch> = match set_auto_wake(config.auto_wake.as_ref()) {
Ok(epoch) => Some(epoch),
Err(e) => {println!("Error setting auto-wake timer: {}", e); None},
};
let _ = register_sigusr1().unwrap_or_else(|x| {
println!("{}", x);
println!("WARNING: Could not register SIGUSR1 handler.");
std::process::exit(1);
});
println!("Configuration valid. Idle detection starting.");
let mut idle_triggered = false;
let mut start = time::OffsetDateTime::now_utc().unix_timestamp();
let mut watchdog = time::OffsetDateTime::now_utc().unix_timestamp();
loop {
let idle = test_idle(&config, start);
// If it's idle, the idle command hasn't already run, and it has been
// at least |idle_time| since the service started: enter idle state.
if idle.is_idle && !idle_triggered {
let tests = test_nonidle(&config);
if !tests.is_blocked {
println!("Idle state active:\n{}{}", idle, tests);
if let Some(ref idle_cmd) = config.on_idle {
println!("System suspending.");
let status = Command::new("sh")
.arg("-c")
.arg(idle_cmd)
.status();
match status {
Ok(_) => { println!("Idle command succeeded."); },
Err(e) => { println!("Idle command failed: {}", e); },
}
}
idle_triggered = true;
}
}
else {
idle_triggered = false;
}
let sleep_time = std::cmp::max(idle.idle_remain, 5000);
let sleep_chunk = 500;
// Sleep for the minimum time needed before the system can possibly
// be idle, but do it in small chunks so we can periodically check
// for signals and clock jumps.
for _ in 0 .. sleep_time / sleep_chunk {
// Print stats when SIGUSR1 signal received
let signaled = SIGUSR_SIGNALED.swap(false, Ordering::SeqCst);
if signaled {
let idle = test_idle(&config, start);
let tests = test_nonidle(&config);
println!("Idle Detection Summary:\n{}{}", idle, tests);
}
let now = time::OffsetDateTime::now_utc().unix_timestamp();
// Look for clock jumps that indicate the system slept
if watchdog + 30 < now {
println!("Watchdog missed. Wake from sleep!");
start = time::OffsetDateTime::now_utc().unix_timestamp();
let idle = test_idle(&config, start);
let tests = test_nonidle(&config);
println!("Idle state on wake:\n{}{}", idle, tests);
if let Some(ref wake_cmd) = config.on_wake {
println!("System waking.");
let status = Command::new("sh")
.arg("-c")
.arg(wake_cmd)
.status();
match status {
Ok(_) => { println!("Wake command succeeded."); },
Err(e) => { println!("Wake command failed: {}", e); },
}
}
}
// Kick watchdog timer frequently, and possibly reschedule auto-wake timer
if watchdog + 10 < now {
current_rtc = reschedule_auto_wake(config.auto_wake.as_ref(), current_rtc);
watchdog = time::OffsetDateTime::now_utc().unix_timestamp();
}
std::thread::sleep(std::time::Duration::from_millis(sleep_chunk));
}
}
}