diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml index 2c1f35f..a6654b7 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml @@ -82,6 +82,7 @@ body: label: Which version of Goral do you run (`goral --version`)? multiple: false options: + - 0.1.9 - 0.1.8 - 0.1.7 - 0.1.6 diff --git a/.github/site/src/install.sh b/.github/site/src/install.sh index e9da6a1..9c36839 100755 --- a/.github/site/src/install.sh +++ b/.github/site/src/install.sh @@ -21,7 +21,7 @@ main() { get_architecture || return 1 local _arch="$RETVAL" - local _version=${1:-'0.1.8'} + local _version=${1:-'0.1.9'} assert_nz "$_arch" "arch" local _file="goral-${_version}-${_arch}" diff --git a/.github/site/src/installation.md b/.github/site/src/installation.md index c6ce5e5..430f210 100644 --- a/.github/site/src/installation.md +++ b/.github/site/src/installation.md @@ -11,9 +11,9 @@ curl --proto '=https' --tlsv1.2 -sSf https://maksimryndin.github.io/goral/instal ```sh -wget https://github.com/maksimryndin/goral/releases/download/0.1.8/goral-0.1.8-x86_64-unknown-linux-gnu.tar.gz -tar -xzf goral-0.1.8-x86_64-unknown-linux-gnu.tar.gz -cd goral-0.1.8-x86_64-unknown-linux-gnu/ +wget https://github.com/maksimryndin/goral/releases/download/0.1.9/goral-0.1.9-x86_64-unknown-linux-gnu.tar.gz +tar -xzf goral-0.1.9-x86_64-unknown-linux-gnu.tar.gz +cd goral-0.1.9-x86_64-unknown-linux-gnu/ shasum -a 256 -c sha256_checksum.txt ``` @@ -23,7 +23,7 @@ shasum -a 256 -c sha256_checksum.txt ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -git clone --depth 1 --branch 0.1.8 https://github.com/maksimryndin/goral +git clone --depth 1 --branch 0.1.9 https://github.com/maksimryndin/goral cd goral RUSTFLAGS='-C target-feature=+crt-static' cargo build --release --target ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1474a7c..20920d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +* 0.1.9 + * reorder system logs fields for charts + * ssh versions checks (for ubuntu) + * system support check (for ubuntu) + * 0.1.8 * fix ssh logs parsing diff --git a/Cargo.lock b/Cargo.lock index 654f1a3..9cda4c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,7 +657,7 @@ dependencies = [ [[package]] name = "goral" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 04c34c0..0807460 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goral" -version = "0.1.8" +version = "0.1.9" edition = "2021" author = "Maksim Ryndin" license = "Apache-2.0" @@ -41,7 +41,7 @@ serde = "^1.0" serde_derive = "^1.0" serde_json = "^1.0" serde_valid = { version = "0.16.3", features = ["toml"] } -sysinfo = { version = "0.30", default_features = false } +sysinfo = { version = "0.30", default-features = false } tokio = { version = "^1.0", features = ["sync", "signal", "io-std", "process", "net"] } tonic = { version = "^0.10", features = ["transport", "prost"]} tonic-health = "0.10.2" diff --git a/src/lib.rs b/src/lib.rs index 3ffcf50..a263eba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,14 +20,13 @@ use services::healthcheck::{HealthcheckService, HEALTHCHECK_SERVICE_NAME}; use services::kv::{KvService, KV_SERVICE_NAME}; use services::logs::{LogsService, LOGS_SERVICE_NAME}; use services::metrics::{MetricsService, METRICS_SERVICE_NAME}; -use services::system::{SystemService, SYSTEM_SERVICE_NAME}; +use services::system::{collector::system_info, SystemService, SYSTEM_SERVICE_NAME}; pub use services::*; use std::collections::HashMap; use std::fmt::{self, Debug}; use std::sync::Arc; use std::time::Duration; pub use storage::*; -use sysinfo::System; use tokio::sync::mpsc::Receiver; pub(crate) type HyperConnector = HttpsConnector; @@ -227,19 +226,18 @@ pub async fn welcome( truncation_check: Result<(), String>, ) { let sys = tokio::task::spawn_blocking(|| { - sysinfo::set_open_files_limit(0); - let sys = System::new_all(); - let mem = sys.total_memory() / 1000 / 1000; + let sys = system_info(); + let mem = sys.total_memory / 1000 / 1000; let mem = if mem > 1000 { format!("{}G", mem / 1000) } else { format!("{mem}M") }; match ( - System::name(), - System::long_os_version(), - System::kernel_version(), - System::host_name(), + sys.name.as_ref(), + sys.long_os_version.as_ref(), + sys.kernel_version.as_ref(), + sys.host_name.as_ref(), ) { (Some(name), Some(os_version), Some(kernel_version), Some(host_name)) => format!( "{name} {os_version}(kernel {kernel_version}); hostname: {host_name}, RAM {mem}" diff --git a/src/services/system/collector.rs b/src/services/system/collector.rs index 2f436b1..082ccad 100644 --- a/src/services/system/collector.rs +++ b/src/services/system/collector.rs @@ -191,6 +191,25 @@ pub(super) fn initialize() -> System { sys } +pub(crate) struct SystemInfo { + pub(crate) name: Option, + pub(crate) long_os_version: Option, + pub(crate) kernel_version: Option, + pub(crate) host_name: Option, + pub(crate) total_memory: u64, +} + +pub(crate) fn system_info() -> SystemInfo { + let sys = initialize(); + SystemInfo { + name: System::name(), + long_os_version: System::long_os_version(), + kernel_version: System::kernel_version(), + host_name: System::host_name(), + total_memory: sys.total_memory(), + } +} + pub(super) fn collect( sys: &mut System, mounts: &[String], @@ -217,25 +236,25 @@ pub(super) fn collect( ) .expect("assert: system boot time timestamp should be valid"); let basic = [ - ("boot_time".to_string(), Datavalue::Datetime(boot_time)), - ( - "memory_available".to_string(), - Datavalue::Size(sys.available_memory()), - ), ( MEMORY_USE.to_string(), // SAFE for percentage calculation to cast from u64 to f64 Datavalue::HeatmapPercent(100.0 * sys.used_memory() as f64 / total_memory as f64), ), - ( - "swap_available".to_string(), - Datavalue::Size(sys.free_swap()), - ), ( SWAP_USE.to_string(), // SAFE for percentage calculation to cast from u64 to f64 Datavalue::HeatmapPercent(100.0 * sys.used_swap() as f64 / sys.total_swap() as f64), ), + ("boot_time".to_string(), Datavalue::Datetime(boot_time)), + ( + "memory_available".to_string(), + Datavalue::Size(sys.available_memory()), + ), + ( + "swap_available".to_string(), + Datavalue::Size(sys.free_swap()), + ), ( "num_of_processes".to_string(), Datavalue::Integer( @@ -252,7 +271,7 @@ pub(super) fn collect( ) }); - let basic_values: Vec<(String, Datavalue)> = basic.into_iter().chain(cpus).collect(); + let basic_values: Vec<(String, Datavalue)> = cpus.into_iter().chain(basic).collect(); // 1 for basic, 5 for top_ stats, 1 for network let mut datarows = Vec::with_capacity(1 + mounts.len() + 5 + names.len() + 1); diff --git a/src/services/system/mod.rs b/src/services/system/mod.rs index 5c569b0..fa86008 100644 --- a/src/services/system/mod.rs +++ b/src/services/system/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod configuration; #[cfg(target_os = "linux")] pub(crate) mod ssh; use crate::google::datavalue::{Datarow, Datavalue}; +use crate::http_client::HttpClient; use crate::messenger::configuration::MessengerConfig; use crate::notifications::{MessengerApi, Notification, Sender}; use crate::rules::{Action, Rule, RuleCondition}; @@ -17,10 +18,37 @@ use std::sync::{ Arc, }; use std::time::Duration; -use tokio::sync::mpsc::{self}; +use tokio::sync::{mpsc, oneshot}; use tokio::task::JoinHandle; pub const SYSTEM_SERVICE_NAME: &str = "system"; +#[cfg(target_os = "linux")] +const MAX_BYTES_SSH_VERSIONS_OUTPUT: usize = 2_usize.pow(16); // ~65 KiB + +#[cfg(target_os = "linux")] +async fn ssh_versions() -> Result> { + let url = "http://changelogs.ubuntu.com/changelogs/pool/main/o/openssh/" + .parse() + .expect("assert: ssh versions url is correct"); + let client = HttpClient::new( + MAX_BYTES_SSH_VERSIONS_OUTPUT, + true, + Duration::from_millis(1000), + url, + ); + let res = client.get().await?; + Ok(res) +} + +enum SystemInfoRequest { + Telemetry(DateTime), + #[cfg(target_os = "linux")] + SshNeedUpdate(String, oneshot::Sender>), + #[cfg(target_os = "linux")] + OSName(oneshot::Sender>), + #[cfg(target_os = "linux")] + IsSupported(oneshot::Sender>), +} pub(crate) struct SystemService { shared: Shared, @@ -64,7 +92,7 @@ impl SystemService { fn collect_sysinfo( is_shutdown: Arc, sender: mpsc::Sender, - mut request_rx: mpsc::Receiver>, + mut request_rx: mpsc::Receiver, mounts: Vec, names: Vec, messenger: Sender, @@ -72,52 +100,220 @@ impl SystemService { let mut sys = collector::initialize(); tracing::info!("started system info scraping thread"); - while let Some(scrape_time) = request_rx.blocking_recv() { - let result = collector::collect( - &mut sys, - &mounts, - &names, - scrape_time.naive_utc(), - &messenger, - ) - .map(Data::Many) - .map_err(|e| Data::Message(format!("sysinfo scraping error {e}"))); - if sender.blocking_send(TaskResult { id: 0, result }).is_err() { - if is_shutdown.load(Ordering::Relaxed) { - tracing::info!("exiting system info scraping thread"); - return; + while let Some(request) = request_rx.blocking_recv() { + match request { + #[cfg(target_os = "linux")] + SystemInfoRequest::SshNeedUpdate(changelog, reply_to) => { + let response = ssh::check_ssh_needs_update(&changelog); + if reply_to.send(response).is_err() { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("exiting system info scraping thread"); + return; + } + panic!( + "assert: ssh update checker recepient shouldn't be closed before shutdown signal" + ); + } + } + #[cfg(target_os = "linux")] + SystemInfoRequest::OSName(reply_to) => { + let collector::SystemInfo { name, .. } = collector::system_info(); + if reply_to.send(name).is_err() { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("exiting system info scraping thread"); + return; + } + panic!( + "assert: os name recepient shouldn't be closed before shutdown signal" + ); + } + } + #[cfg(target_os = "linux")] + SystemInfoRequest::IsSupported(reply_to) => { + let response = ssh::is_system_still_supported(); + if reply_to.send(response).is_err() { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("exiting system info scraping thread"); + return; + } + panic!( + "assert: system support check recepient shouldn't be closed before shutdown signal" + ); + } + } + SystemInfoRequest::Telemetry(scrape_time) => { + let result = collector::collect( + &mut sys, + &mounts, + &names, + scrape_time.naive_utc(), + &messenger, + ) + .map(Data::Many) + .map_err(|e| Data::Message(format!("sysinfo scraping error {e}"))); + if sender.blocking_send(TaskResult { id: 0, result }).is_err() { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("exiting system info scraping thread"); + return; + } + panic!("assert: sysinfo messages queue shouldn't be closed before shutdown signal"); + } } - panic!("assert: sysinfo messages queue shouldn't be closed before shutdown signal"); } } } - async fn make_timed_scrape( - request_tx: &mut mpsc::Sender>, - scrape_timeout: Duration, - scrape_time: DateTime, + async fn make_system_request( + request_tx: &mut mpsc::Sender, + timeout: Duration, + request: SystemInfoRequest, ) -> Result<(), String> { tokio::select! { - _ = tokio::time::sleep(scrape_timeout) => Err(format!("sysinfo scrape timeout {:?}", scrape_timeout)), - res = request_tx.send(scrape_time) => res.map_err(|e| e.to_string()) + _ = tokio::time::sleep(timeout) => Err(format!("sysinfo request timeout {:?}", timeout)), + res = request_tx.send(request) => res.map_err(|e| e.to_string()) } } #[cfg(target_os = "linux")] - async fn ssh_observer( + async fn ssh_access_observer( is_shutdown: Arc, sender: mpsc::Sender, + send_notification: Sender, messenger: Sender, ) { - tracing::info!("starting ssh monitoring"); + tracing::info!("starting ssh access monitoring"); let (tx, rx) = tokio::sync::oneshot::channel::<()>(); std::thread::Builder::new() - .name("ssh-observer".into()) - .spawn(move || ssh::process_sshd_log(is_shutdown, sender, messenger, tx)) - .expect("assert: can spawn ssh monitoring thread"); + .name("ssh-access-observer".into()) + .spawn(move || { + ssh::process_sshd_log(is_shutdown, sender, send_notification, messenger, tx) + }) + .expect("assert: can spawn ssh access monitoring thread"); let _ = rx.await; } + #[cfg(target_os = "linux")] + async fn system_checker( + is_shutdown: Arc, + send_notification: Sender, + messenger: Sender, + mut sys_req_tx: mpsc::Sender, + ) { + let mut ssh_interval = tokio::time::interval(Duration::from_secs(4 * 60 * 60)); + let mut system_support_interval = + tokio::time::interval(Duration::from_secs(60 * 60 * 24 * 3)); + let request_timeout = Duration::from_secs(5); + tracing::info!("starting system updates checking"); + loop { + tokio::select! { + _ = ssh_interval.tick() => { + let source = match ssh_versions().await { + Ok(source) => source, + Err(e) => { + let msg = format!("error sending ssh versions request `{}`", e); + tracing::error!("{}", msg); + messenger.error(msg).await; + continue; + } + }; + let (tx, rx) = oneshot::channel(); + if let Err(e) = Self::make_system_request(&mut sys_req_tx, request_timeout, SystemInfoRequest::SshNeedUpdate(source, tx)).await { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("finished system updates checking"); + return; + } + let msg = format!("error making ssh version check request `{}`", e); + tracing::error!("{}", msg); + send_notification.fatal(msg).await; + } + match rx.await { + Ok(Ok(true)) => { + let msg = "openssh patch version is outdated, update with `sudo apt update && sudo apt install openssh-server`".to_string(); + tracing::warn!("{}", msg); + messenger.warn(msg).await; + }, + Ok(Ok(false)) => continue, + + Ok(Err(message)) => { + let msg = format!("error fetching ssh version update `{message}`"); + tracing::error!("{}", msg); + send_notification.error(msg).await; + } + Err(e) => { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("finished system updates checking"); + return; + } + let msg = format!("error fetching ssh version update `{e}`"); + tracing::error!("{}", msg); + send_notification.fatal(msg).await; + } + }; + } + _ = system_support_interval.tick() => { + let (tx, rx) = oneshot::channel(); + if let Err(e) = Self::make_system_request(&mut sys_req_tx, request_timeout, SystemInfoRequest::IsSupported(tx)).await { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("finished system updates checking"); + return; + } + let msg = format!("error making system support check request `{}`", e); + tracing::error!("{}", msg); + send_notification.fatal(msg).await; + } + match rx.await { + Ok(Ok(false)) => { + let msg = "the system seems to be [no longer supported](https://wiki.ubuntu.com/Releases)".to_string(); + tracing::warn!("{}", msg); + messenger.warn(msg).await; + }, + Ok(Ok(true)) => continue, + + Ok(Err(message)) => { + let msg = format!("error fetching system support check `{message}`"); + tracing::error!("{}", msg); + } + Err(e) => { + if is_shutdown.load(Ordering::Relaxed) { + tracing::info!("finished system updates checking"); + return; + } + let msg = format!("error fetching system updates `{e}`"); + tracing::error!("{}", msg); + send_notification.fatal(msg).await; + } + }; + } + } + } + } + + #[cfg(target_os = "linux")] + async fn fetch_os_name( + is_shutdown: &Arc, + sys_req_tx: &mut mpsc::Sender, + send_notification: &Sender, + ) -> Option { + let (tx, rx) = oneshot::channel(); + if let Err(e) = Self::make_system_request( + sys_req_tx, + Duration::from_millis(500), + SystemInfoRequest::OSName(tx), + ) + .await + { + if !is_shutdown.load(Ordering::Relaxed) { + let msg = format!("error requesting os name `{}`", e); + tracing::error!("{}", msg); + send_notification.fatal(msg).await; + } + None + } else { + rx.await + .expect("assert: os name sender shouldn't be dropped") + } + } + #[allow(clippy::too_many_arguments)] async fn sys_observer( is_shutdown: Arc, @@ -128,9 +324,10 @@ impl SystemService { send_notification: Sender, mounts: Vec, names: Vec, + sys_req_rx: mpsc::Receiver, + mut sys_req_tx: mpsc::Sender, ) { let mut interval = tokio::time::interval(scrape_interval); - let (mut tx, rx) = mpsc::channel::>(1); tracing::info!("starting system info scraping"); let cloned_sender = sender.clone(); let cloned_is_shutdown = is_shutdown.clone(); @@ -140,7 +337,7 @@ impl SystemService { Self::collect_sysinfo( cloned_is_shutdown, cloned_sender, - rx, + sys_req_rx, mounts, names, messenger, @@ -152,7 +349,7 @@ impl SystemService { tokio::select! { _ = interval.tick() => { let scrape_time = Utc::now(); - if let Err(e) = Self::make_timed_scrape(&mut tx, scrape_timeout, scrape_time).await { + if let Err(e) = Self::make_system_request(&mut sys_req_tx, scrape_timeout, SystemInfoRequest::Telemetry(scrape_time)).await { if is_shutdown.load(Ordering::Relaxed) { tracing::info!("finished sysinfo collection"); return; @@ -305,7 +502,6 @@ impl Service for SystemService { sender: mpsc::Sender, ) -> Vec> { let is_shutdown = is_shutdown.clone(); - let sender = sender.clone(); let send_notification = self.shared.send_notification.clone(); let messenger = self .messenger() @@ -314,31 +510,60 @@ impl Service for SystemService { let names = self.process_names.clone(); let scrape_interval = self.scrape_interval; let scrape_timeout = self.scrape_timeout; + let (sys_req_tx, sys_req_rx) = mpsc::channel::(2); - let mut tasks = vec![]; - #[cfg(target_os = "linux")] - { - let cloned_is_shutdown = is_shutdown.clone(); - let cloned_sender = sender.clone(); - let cloned_messenger = messenger.clone(); - tasks.push(tokio::spawn(async move { - Self::ssh_observer(cloned_is_shutdown, cloned_sender, cloned_messenger).await; - })); - } - + let cloned_is_shutdown = is_shutdown.clone(); + let cloned_sys_req_tx = sys_req_tx.clone(); + let cloned_sender = sender.clone(); + let mut tasks = Vec::with_capacity(3); + // a separate push to remove cargo clippy warning tasks.push(tokio::spawn(async move { Self::sys_observer( - is_shutdown, + cloned_is_shutdown, scrape_interval, scrape_timeout, - sender, + cloned_sender, messenger, send_notification, mounts, names, + sys_req_rx, + cloned_sys_req_tx, ) .await; })); + + #[cfg(target_os = "linux")] + { + let cloned_is_shutdown = is_shutdown.clone(); + let cloned_sender = sender.clone(); + let send_notification = self.shared.send_notification.clone(); + let messenger = self + .messenger() + .unwrap_or(self.shared.send_notification.clone()); + tasks.push(tokio::spawn(async move { + Self::ssh_access_observer( + cloned_is_shutdown, + cloned_sender, + send_notification, + messenger, + ) + .await; + })); + let send_notification = self.shared.send_notification.clone(); + let mut sys_req_tx = sys_req_tx; + let os_name = + Self::fetch_os_name(&is_shutdown, &mut sys_req_tx, &send_notification).await; + if let Some(true) = os_name.map(|name| name.to_lowercase().contains("ubuntu")) { + let messenger = self + .messenger() + .unwrap_or(self.shared.send_notification.clone()); + tasks.push(tokio::spawn(async move { + Self::system_checker(is_shutdown, send_notification, messenger, sys_req_tx) + .await; + })); + } + } tasks } } @@ -356,6 +581,7 @@ mod tests { let (send_notification, mut notifications_receiver) = mpsc::channel(1); let send_notification = Sender::new(send_notification, "test"); let (data_sender, mut data_receiver) = mpsc::channel(NUM_OF_SCRAPES); + let (req_sender, req_receiver) = mpsc::channel(2); let is_shutdown = Arc::new(AtomicBool::new(false)); let notifications = tokio::spawn(async move { @@ -375,6 +601,8 @@ mod tests { send_notification, vec![], vec![], + req_receiver, + req_sender, ) .await; }); @@ -411,6 +639,7 @@ mod tests { let (send_notification, mut notifications_receiver) = mpsc::channel(1); let send_notification = Sender::new(send_notification, SYSTEM_SERVICE_NAME); let (data_sender, mut data_receiver) = mpsc::channel(1); + let (req_sender, req_receiver) = mpsc::channel(2); let is_shutdown = Arc::new(AtomicBool::new(false)); let notification = tokio::spawn(async move { notifications_receiver.recv().await }); @@ -426,16 +655,21 @@ mod tests { send_notification, vec![], vec![], + req_receiver, + req_sender, ) .await; }); if let Some(Notification { message, level }) = notification.await.unwrap() { - assert!(message.contains("sysinfo scrape timeout")); + assert!( + message.contains("sysinfo request timeout"), + "received notification: {}", + message + ); assert_eq!(level, Level::ERROR); } else { panic!("test assert: at least one timeout should be happen"); } - is_shutdown.store(true, Ordering::Release); data_receiver.close(); scrape_handle.await.unwrap(); // scrape should finish as the data channel is closed diff --git a/src/services/system/ssh.rs b/src/services/system/ssh.rs index 3748891..7daeb77 100644 --- a/src/services/system/ssh.rs +++ b/src/services/system/ssh.rs @@ -1,11 +1,12 @@ use crate::google::datavalue::{Datarow, Datavalue}; use crate::notifications::{Notification, Sender}; use crate::services::{Data, TaskResult}; -use chrono::{NaiveDateTime, Utc}; +use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; use lazy_static::lazy_static; use logwatcher::{LogWatcher, LogWatcherAction, LogWatcherEvent}; -use regex::Regex; +use regex::{Regex, RegexBuilder}; use std::collections::HashMap; +use std::process::Command; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -22,6 +23,7 @@ pub const SSH_LOG_STATUS_TERMINATED: &str = "terminated"; pub(super) fn process_sshd_log( is_shutdown: Arc, sender: mpsc::Sender, + send_notification: Sender, messenger: Sender, tx: TokioSender<()>, ) { @@ -33,10 +35,10 @@ pub(super) fn process_sshd_log( Ok(f) => f, Err(_) => { let message = - "cannot open auth log file, tried paths /var/log/auth.log and /var/log/secure" + "cannot open auth log file, tried paths `/var/log/auth.log` and `/var/log/secure`" .to_string(); tracing::error!("{}, exiting ssh monitoring thread", message); - messenger.send_nonblock(Notification::new(message, Level::ERROR)); + send_notification.send_nonblock(Notification::new(message, Level::ERROR)); return; } }; @@ -253,6 +255,124 @@ fn parse(line: &str) -> Option { }) } +// Ubuntu +fn get_latest_ssh_version_and_patch<'a, 'b>( + changelog: &'a str, + current_version: &'b str, + current_patch: &'b str, +) -> Option<(&'a str, &'a str)> { + lazy_static! { + static ref RE: Regex = Regex::new(r#"href="openssh_(\d+\.\d+p\d+)-([^/]+)/""#) + .expect("assert: ssh versions changelog regex is properly constructed"); + } + + RE.captures_iter(changelog) + .map(|c| { + let (_, [v, p]) = c.extract(); + (v, p) + }) + .skip_while(|(v, p)| *v != current_version && *p != current_patch) + .reduce(|(_, latest_patch), (v, p)| { + if v == current_version { + (v, p) + } else { + (v, latest_patch) + } + }) +} + +fn get_system_ssh_version_and_patch() -> Result { + match Command::new("ssh").arg("-V").output() { + Ok(output) if output.status.success() => { + Ok(String::from_utf8_lossy(&output.stderr).to_string()) + } + Ok(output) => Err(String::from_utf8_lossy(&output.stderr).to_string()), + Err(e) => Err(format!( + "failed to execute ssh version command with error {e}" + )), + } +} + +// Ubuntu +fn parse_ssh_version_and_patch(command_output: &str) -> Option<(&str, &str)> { + lazy_static! { + static ref RE: Regex = RegexBuilder::new(r#"openssh_(\d+\.\d+p\d+) ubuntu-([^,]+),"#) + .case_insensitive(true) + .build() + .expect("assert: ssh version command regex is properly constructed"); + } + RE.captures(command_output).map(|capture| { + let (_, [version, patch]) = capture.extract(); + (version, patch) + }) +} + +pub(super) fn check_ssh_needs_update(changelog: &str) -> Result { + let output = get_system_ssh_version_and_patch()?; + let (version, patch) = parse_ssh_version_and_patch(&output) + .ok_or_else(|| "failed to parse ssh version command output".to_string())?; + let (_, latest_patch) = get_latest_ssh_version_and_patch(changelog, version, patch) + .ok_or_else(|| "failed to parse ssh changelog".to_string())?; + Ok(latest_patch != patch) +} + +const MONTHS: [&str; 12] = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +]; + +fn parse_lts_end(command_output: &str) -> Option> { + lazy_static! { + static ref RE: Regex = RegexBuilder::new(r#"(january|february|march|april|may|june|july|august|september|october|november|december)\s(\d{4})"#) + .case_insensitive(true) + .build() + .expect("assert: system end of support command regex is properly constructed"); + } + let (month, year) = RE.captures(command_output).map(|capture| { + let (_, [month, year]) = capture.extract(); + (month, year) + })?; + let month = month.to_lowercase(); + let month: u32 = MONTHS + .into_iter() + .position(|m| m == month)? + .try_into() + .ok()?; + let next_month = (month + 2) % 12; + let mut year: i32 = year.parse().ok()?; + if next_month == 1 { + year += 1; + } + // should not fail + // https://docs.rs/chrono/latest/chrono/offset/type.MappedLocalTime.html#method.unwrap + Some(Utc.with_ymd_and_hms(year, next_month, 1, 0, 0, 0).unwrap()) +} + +pub(super) fn is_system_still_supported() -> Result { + let output = match Command::new("hwe-support-status").arg("--verbose").output() { + Ok(output) if output.status.success() => { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + Ok(output) => Err(String::from_utf8_lossy(&output.stderr).to_string()), + Err(e) => Err(format!( + "failed to execute ssh version command with error {e}" + )), + }?; + let supported_till = parse_lts_end(&output) + .ok_or_else(|| format!("failed to parse hwe-support-status output {output}"))?; + Ok(Utc::now() < supported_till) +} + #[cfg(test)] mod tests { use super::*; @@ -477,4 +597,43 @@ mod tests { Datavalue::Text("RSA SHA256:D726XJ0DkstyhsyH2rAbfYuIaeBOa3Su2l2WWbyXnXs".to_string()) ); } + + #[test] + fn ssh_versions_response_parse() { + let response = r#""" + href="openssh_8.7p1-4/" + href="openssh_8.9p1-3ubuntu0.3/" + href="openssh_8.9p1-3ubuntu0.10/" + href="openssh_9.0p1-1ubuntu8.4/" + + """#; + assert_eq!( + Some(("9.0p1", "3ubuntu0.10")), + get_latest_ssh_version_and_patch(response, "8.9p1", "3ubuntu0.3") + ); + } + + #[test] + fn ssh_version_command_parse() { + let output = "OpenSSH_8.9p1 Ubuntu-3ubuntu0.10, OpenSSL 3.0.2 15 Mar 2022"; + assert_eq!( + Some(("8.9p1", "3ubuntu0.10")), + parse_ssh_version_and_patch(output) + ); + } + + #[test] + fn parse_lts_support() { + let output = "You are not running a system with a Hardware Enablement Stack. Your system is supported until April 2027."; + assert_eq!( + Utc.with_ymd_and_hms(2027, 5, 1, 0, 0, 0).unwrap(), + parse_lts_end(output).unwrap() + ); + + let output = "You are not running a system with a Hardware Enablement Stack. Your system is supported until December 2027."; + assert_eq!( + Utc.with_ymd_and_hms(2028, 1, 1, 0, 0, 0).unwrap(), + parse_lts_end(output).unwrap() + ); + } }