From 065aaf4df9cc1e15fb600baa9107d7511a4a6f13 Mon Sep 17 00:00:00 2001 From: Kunal Mehta Date: Thu, 14 Nov 2024 18:58:29 -0500 Subject: [PATCH] Split noble-migration check logic into reusable lib As part of the upgrade script, we want to run the check one last time to ensure that everything is ready to go. Instead of shelling out to it, move the logc into a Rust library that can be shared by both binaries. --- noble-migration/src/bin/check.rs | 303 +----------------------------- noble-migration/src/lib.rs | 307 +++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+), 299 deletions(-) create mode 100644 noble-migration/src/lib.rs diff --git a/noble-migration/src/bin/check.rs b/noble-migration/src/bin/check.rs index 0a92ac74b0..0244165b84 100644 --- a/noble-migration/src/bin/check.rs +++ b/noble-migration/src/bin/check.rs @@ -4,16 +4,9 @@ //! //! It is typically run by a systemd service/timer, but we also //! support admins running it manually to get more detailed output. -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use rustix::process::geteuid; -use serde::Serialize; -use std::{ - fs, - path::Path, - process::{self, ExitCode}, -}; -use url::{Host, Url}; -use walkdir::WalkDir; +use std::{fs, process::ExitCode}; /// This file contains the state of the pre-migration checks. /// @@ -24,253 +17,8 @@ use walkdir::WalkDir; /// * JSON object with boolean values for each check (see `State` struct) const STATE_PATH: &str = "/etc/securedrop-noble-migration.json"; -#[derive(Serialize)] -struct State { - ssh: bool, - ufw: bool, - free_space: bool, - apt: bool, - systemd: bool, -} - -impl State { - fn is_ready(&self) -> bool { - self.ssh && self.ufw && self.free_space && self.apt && self.systemd - } -} - -/// Parse the OS codename from /etc/os-release -fn os_codename() -> Result { - let contents = fs::read_to_string("/etc/os-release") - .context("reading /etc/os-release failed")?; - for line in contents.lines() { - if line.starts_with("VERSION_CODENAME=") { - // unwrap: Safe because we know the line contains "=" - let (_, codename) = line.split_once("=").unwrap(); - return Ok(codename.trim().to_string()); - } - } - - bail!("Could not find VERSION_CODENAME in /etc/os-release") -} - -/// Check that the UNIX "ssh" group has no members -/// -/// See . -fn check_ssh_group() -> Result { - // There are no clean bindings to getgrpname in rustix, - // so jut shell out to getent to get group members - let output = process::Command::new("getent") - .arg("group") - .arg("ssh") - .output() - .context("spawning getent failed")?; - if output.status.code() == Some(2) { - println!("ssh OK: group does not exist"); - return Ok(true); - } else if !output.status.success() { - bail!( - "running getent failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let stdout = String::from_utf8(output.stdout) - .context("getent stdout is not utf-8")?; - let members = parse_getent_output(&stdout)?; - if members.is_empty() { - println!("ssh OK: group is empty"); - Ok(true) - } else { - println!("ssh ERROR: group is not empty: {members:?}"); - Ok(false) - } -} - -/// Parse the output of `getent group ssh`, return true if empty -fn parse_getent_output(stdout: &str) -> Result> { - let stdout = stdout.trim(); - // The format looks like `ssh:x:123:member1,member2` - if !stdout.contains(":") { - bail!("unexpected output from getent: '{stdout}'"); - } - - // unwrap: safe, we know the line contains ":" - let (_, members) = stdout.rsplit_once(':').unwrap(); - if members.is_empty() { - Ok(vec![]) - } else { - Ok(members.split(',').collect()) - } -} - -/// Check that ufw was removed -/// -/// See . -fn check_ufw_removed() -> bool { - if Path::new("/usr/sbin/ufw").exists() { - println!("ufw ERROR: ufw is still installed"); - false - } else { - println!("ufw OK: ufw was removed"); - true - } -} - -/// Estimate the size of the backup so we know how much free space we'll need. -/// -/// We just check the size of `/var/lib/securedrop` since that's really the -/// data that'll take up space; everything else is just config files that are -/// negligible post-compression. We also don't estimate compression benefits. -fn estimate_backup_size() -> Result { - let path = Path::new("/var/lib/securedrop"); - if !path.exists() { - // mon server - return Ok(0); - } - let mut total: u64 = 0; - let walker = WalkDir::new(path); - for entry in walker { - let entry = entry.context("walking /var/lib/securedrop failed")?; - if entry.file_type().is_dir() { - continue; - } - let metadata = entry.metadata().context("getting metadata failed")?; - total += metadata.len(); - } - - Ok(total) -} - -/// We want to have enough space for a backup, the upgrade (~4GB of packages, -/// conservatively), and not take up more than 90% of the disk. -fn check_free_space() -> Result { - // Also no simple bindings to get disk size, so shell out to df - // Explicitly specify -B1 for bytes (not kilobytes) - let output = process::Command::new("df") - .args(["-B1", "/"]) - .output() - .context("spawning df failed")?; - if !output.status.success() { - bail!( - "running df failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let stdout = - String::from_utf8(output.stdout).context("df stdout is not utf-8")?; - let parsed = parse_df_output(&stdout)?; - - let backup_needs = estimate_backup_size()?; - let upgrade_needs: u64 = 4 * 1024 * 1024 * 1024; // 4GB - let headroom = parsed.total / 10; // 10% headroom - let total_needs = backup_needs + upgrade_needs + headroom; - - if parsed.free < total_needs { - println!( - "free space ERROR: not enough free space, have {} free bytes, need {total_needs} bytes", - parsed.free - ); - Ok(false) - } else { - println!("free space OK: enough free space"); - Ok(true) - } -} - -/// Sizes are in bytes -struct DfOutput { - total: u64, - free: u64, -} - -fn parse_df_output(stdout: &str) -> Result { - let line = match stdout.split_once('\n') { - Some((_, line)) => line, - None => bail!("df output didn't have a newline"), - }; - let parts: Vec<_> = line.split_whitespace().collect(); - - if parts.len() < 4 { - bail!("df output didn't have enough columns"); - } - - // vec indexing is safe because we did the bounds check above - let total = parts[1] - .parse::() - .context("parsing total space failed")?; - let free = parts[3] - .parse::() - .context("parsing free space failed")?; - - Ok(DfOutput { total, free }) -} - -const EXPECTED_DOMAINS: [&str; 3] = [ - "archive.ubuntu.com", - "security.ubuntu.com", - "apt.freedom.press", -]; - -const TEST_DOMAINS: [&str; 2] = - ["apt-qa.freedom.press", "apt-test.freedom.press"]; - -/// Verify only expected sources are configured for apt -fn check_apt() -> Result { - let output = process::Command::new("apt-get") - .arg("indextargets") - .output() - .context("spawning apt-get indextargets failed")?; - if !output.status.success() { - bail!( - "running apt-get indextargets failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let stdout = String::from_utf8(output.stdout) - .context("apt-get stdout is not utf-8")?; - for line in stdout.lines() { - if line.starts_with("URI:") { - let uri = line.strip_prefix("URI: ").unwrap(); - let parsed = Url::parse(uri)?; - if let Some(Host::Domain(domain)) = parsed.host() { - if TEST_DOMAINS.contains(&domain) { - println!("apt: WARNING test source found ({domain})"); - } else if !EXPECTED_DOMAINS.contains(&domain) { - println!("apt ERROR: unexpected source: {domain}"); - return Ok(false); - } - } else { - println!("apt ERROR: unexpected source: {uri}"); - return Ok(false); - } - } - } - - println!("apt OK: all sources are expected"); - Ok(true) -} - -/// Check that systemd has no failed units -fn check_systemd() -> Result { - let output = process::Command::new("systemctl") - .arg("is-failed") - .output() - .context("spawning systemctl failed")?; - if output.status.success() { - // success means some units are failed - println!("systemd ERROR: some units are failed"); - Ok(false) - } else { - println!("systemd OK: all units are happy"); - Ok(true) - } -} - fn run() -> Result<()> { - let codename = os_codename()?; + let codename = noble_migration::os_codename()?; if codename != "focal" { println!("Unsupported Ubuntu version: {codename}"); // nothing to do, write an empty JSON blob @@ -278,13 +26,7 @@ fn run() -> Result<()> { return Ok(()); } - let state = State { - ssh: check_ssh_group()?, - ufw: check_ufw_removed(), - free_space: check_free_space()?, - apt: check_apt()?, - systemd: check_systemd()?, - }; + let state = noble_migration::run_checks()?; fs::write( STATE_PATH, @@ -326,40 +68,3 @@ fn main() -> Result { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_getent_output() { - // no members - assert_eq!( - parse_getent_output("ssh:x:123:\n").unwrap(), - Vec::<&str>::new() - ); - // one member - assert_eq!( - parse_getent_output("ssh:x:123:member1\n").unwrap(), - vec!["member1"] - ); - // two members - assert_eq!( - parse_getent_output("ssh:x:123:member1,member2\n").unwrap(), - vec!["member1", "member2"] - ); - } - - #[test] - fn test_parse_df_output() { - let output = parse_df_output( - "Filesystem 1B-blocks Used Available Use% Mounted on -/dev/mapper/ubuntu--vg-ubuntu--lv 105089261568 8573784064 91129991168 9% / -", - ) - .unwrap(); - - assert_eq!(output.total, 105089261568); - assert_eq!(output.free, 91129991168); - } -} diff --git a/noble-migration/src/lib.rs b/noble-migration/src/lib.rs new file mode 100644 index 0000000000..7cfd7b09e2 --- /dev/null +++ b/noble-migration/src/lib.rs @@ -0,0 +1,307 @@ +//! Common code for the noble-migration that is used by check and upgrade +use anyhow::{bail, Context, Result}; +use serde::Serialize; +use std::{ + fs, + path::Path, + process::{self}, +}; +use url::{Host, Url}; +use walkdir::WalkDir; + +#[derive(Serialize)] +pub struct State { + ssh: bool, + ufw: bool, + free_space: bool, + apt: bool, + systemd: bool, +} + +impl State { + pub fn is_ready(&self) -> bool { + self.is_ready_except_apt() && self.apt + } + + /// For when developers inject extra APT sources for testing + pub fn is_ready_except_apt(&self) -> bool { + self.ssh && self.ufw && self.free_space && self.systemd + } +} + +/// Parse the OS codename from /etc/os-release +pub fn os_codename() -> Result { + let contents = fs::read_to_string("/etc/os-release") + .context("reading /etc/os-release failed")?; + for line in contents.lines() { + if line.starts_with("VERSION_CODENAME=") { + // unwrap: Safe because we know the line contains "=" + let (_, codename) = line.split_once("=").unwrap(); + return Ok(codename.trim().to_string()); + } + } + + bail!("Could not find VERSION_CODENAME in /etc/os-release") +} + +/// Check that the UNIX "ssh" group has no members +/// +/// See . +fn check_ssh_group() -> Result { + // There are no clean bindings to getgrpname in rustix, + // so jut shell out to getent to get group members + let output = process::Command::new("getent") + .arg("group") + .arg("ssh") + .output() + .context("spawning getent failed")?; + if output.status.code() == Some(2) { + println!("ssh OK: group does not exist"); + return Ok(true); + } else if !output.status.success() { + bail!( + "running getent failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8(output.stdout) + .context("getent stdout is not utf-8")?; + let members = parse_getent_output(&stdout)?; + if members.is_empty() { + println!("ssh OK: group is empty"); + Ok(true) + } else { + println!("ssh ERROR: group is not empty: {members:?}"); + Ok(false) + } +} + +/// Parse the output of `getent group ssh`, return true if empty +fn parse_getent_output(stdout: &str) -> Result> { + let stdout = stdout.trim(); + // The format looks like `ssh:x:123:member1,member2` + if !stdout.contains(":") { + bail!("unexpected output from getent: '{stdout}'"); + } + + // unwrap: safe, we know the line contains ":" + let (_, members) = stdout.rsplit_once(':').unwrap(); + if members.is_empty() { + Ok(vec![]) + } else { + Ok(members.split(',').collect()) + } +} + +/// Check that ufw was removed +/// +/// See . +fn check_ufw_removed() -> bool { + if Path::new("/usr/sbin/ufw").exists() { + println!("ufw ERROR: ufw is still installed"); + false + } else { + println!("ufw OK: ufw was removed"); + true + } +} + +/// Estimate the size of the backup so we know how much free space we'll need. +/// +/// We just check the size of `/var/lib/securedrop` since that's really the +/// data that'll take up space; everything else is just config files that are +/// negligible post-compression. We also don't estimate compression benefits. +fn estimate_backup_size() -> Result { + let path = Path::new("/var/lib/securedrop"); + if !path.exists() { + // mon server + return Ok(0); + } + let mut total: u64 = 0; + let walker = WalkDir::new(path); + for entry in walker { + let entry = entry.context("walking /var/lib/securedrop failed")?; + if entry.file_type().is_dir() { + continue; + } + let metadata = entry.metadata().context("getting metadata failed")?; + total += metadata.len(); + } + + Ok(total) +} + +/// We want to have enough space for a backup, the upgrade (~4GB of packages, +/// conservatively), and not take up more than 90% of the disk. +fn check_free_space() -> Result { + // Also no simple bindings to get disk size, so shell out to df + // Explicitly specify -B1 for bytes (not kilobytes) + let output = process::Command::new("df") + .args(["-B1", "/"]) + .output() + .context("spawning df failed")?; + if !output.status.success() { + bail!( + "running df failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = + String::from_utf8(output.stdout).context("df stdout is not utf-8")?; + let parsed = parse_df_output(&stdout)?; + + let backup_needs = estimate_backup_size()?; + let upgrade_needs: u64 = 4 * 1024 * 1024 * 1024; // 4GB + let headroom = parsed.total / 10; // 10% headroom + let total_needs = backup_needs + upgrade_needs + headroom; + + if parsed.free < total_needs { + println!( + "free space ERROR: not enough free space, have {} free bytes, need {total_needs} bytes", + parsed.free + ); + Ok(false) + } else { + println!("free space OK: enough free space"); + Ok(true) + } +} + +/// Sizes are in bytes +struct DfOutput { + total: u64, + free: u64, +} + +fn parse_df_output(stdout: &str) -> Result { + let line = match stdout.split_once('\n') { + Some((_, line)) => line, + None => bail!("df output didn't have a newline"), + }; + let parts: Vec<_> = line.split_whitespace().collect(); + + if parts.len() < 4 { + bail!("df output didn't have enough columns"); + } + + // vec indexing is safe because we did the bounds check above + let total = parts[1] + .parse::() + .context("parsing total space failed")?; + let free = parts[3] + .parse::() + .context("parsing free space failed")?; + + Ok(DfOutput { total, free }) +} + +const EXPECTED_DOMAINS: [&str; 3] = [ + "archive.ubuntu.com", + "security.ubuntu.com", + "apt.freedom.press", +]; + +const TEST_DOMAINS: [&str; 2] = + ["apt-qa.freedom.press", "apt-test.freedom.press"]; + +/// Verify only expected sources are configured for apt +fn check_apt() -> Result { + let output = process::Command::new("apt-get") + .arg("indextargets") + .output() + .context("spawning apt-get indextargets failed")?; + if !output.status.success() { + bail!( + "running apt-get indextargets failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8(output.stdout) + .context("apt-get stdout is not utf-8")?; + for line in stdout.lines() { + if line.starts_with("URI:") { + let uri = line.strip_prefix("URI: ").unwrap(); + let parsed = Url::parse(uri)?; + if let Some(Host::Domain(domain)) = parsed.host() { + if TEST_DOMAINS.contains(&domain) { + println!("apt: WARNING test source found ({domain})"); + } else if !EXPECTED_DOMAINS.contains(&domain) { + println!("apt ERROR: unexpected source: {domain}"); + return Ok(false); + } + } else { + println!("apt ERROR: unexpected source: {uri}"); + return Ok(false); + } + } + } + + println!("apt OK: all sources are expected"); + Ok(true) +} + +/// Check that systemd has no failed units +pub fn check_systemd() -> Result { + let output = process::Command::new("systemctl") + .arg("is-failed") + .output() + .context("spawning systemctl failed")?; + if output.status.success() { + // success means some units are failed + println!("systemd ERROR: some units are failed"); + Ok(false) + } else { + println!("systemd OK: all units are happy"); + Ok(true) + } +} + +pub fn run_checks() -> Result { + Ok(State { + ssh: check_ssh_group()?, + ufw: check_ufw_removed(), + free_space: check_free_space()?, + apt: check_apt()?, + systemd: check_systemd()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_getent_output() { + // no members + assert_eq!( + parse_getent_output("ssh:x:123:\n").unwrap(), + Vec::<&str>::new() + ); + // one member + assert_eq!( + parse_getent_output("ssh:x:123:member1\n").unwrap(), + vec!["member1"] + ); + // two members + assert_eq!( + parse_getent_output("ssh:x:123:member1,member2\n").unwrap(), + vec!["member1", "member2"] + ); + } + + #[test] + fn test_parse_df_output() { + let output = parse_df_output( + "Filesystem 1B-blocks Used Available Use% Mounted on +/dev/mapper/ubuntu--vg-ubuntu--lv 105089261568 8573784064 91129991168 9% / +", + ) + .unwrap(); + + assert_eq!(output.total, 105089261568); + assert_eq!(output.free, 91129991168); + } +}