-
Notifications
You must be signed in to change notification settings - Fork 685
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a basic noble migration check script
Perform a number of checks to ensure the system is ready for the noble migration. The results are written to a JSON file in /etc/ that other things like the JI and the upgrade script itself can read from. The script is run hourly on a systemd timer but can also be run interactively for administrators who want slightly more details. Refs #7322.
- Loading branch information
Showing
10 changed files
with
1,679 additions
and
177 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,313 @@ | ||
//! Check migration of a SecureDrop server from focal to noble | ||
//! | ||
//! This script is run as root on both the app and mon servers. | ||
//! | ||
//! 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 rustix::process::geteuid; | ||
use serde::Serialize; | ||
use std::{ | ||
fs, | ||
path::Path, | ||
process::{self, ExitCode}, | ||
}; | ||
use url::{Host, Url}; | ||
|
||
/// This file contains the state of the pre-migration checks. | ||
/// | ||
/// There are four possible states: | ||
/// * does not exist: check script hasn't run yet | ||
/// * empty JSON object: script determines it isn't on focal | ||
/// * {"error": true}: script encountered an error | ||
/// * 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<String> { | ||
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 <https://github.com/freedomofpress/securedrop/issues/7316>. | ||
fn check_ssh_group() -> Result<bool> { | ||
// 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: 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: group is empty"); | ||
Ok(true) | ||
} else { | ||
println!("ssh: 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<Vec<&str>> { | ||
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 <https://github.com/freedomofpress/securedrop/issues/7313>. | ||
fn check_ufw_removed() -> bool { | ||
if Path::new("/usr/sbin/ufw").exists() { | ||
println!("ufw: ufw is still installed"); | ||
false | ||
} else { | ||
println!("ufw: ufw was removed"); | ||
true | ||
} | ||
} | ||
|
||
const DESIRED_FREE_SPACE: u64 = 10 * 1024 * 1024 * 1024; // 10GB | ||
|
||
/// Check that there is enough free space for taking a backup | ||
/// and downloading all the new packages. 10GB is a bit of an | ||
/// over-estimate, but it should leave enough headroom for everyone. | ||
fn check_free_space() -> Result<bool> { | ||
// Also no simple bindings to get disk size, so shell out to df | ||
let output = process::Command::new("df") | ||
.arg("/") | ||
.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 free_space = parse_df_output(&stdout)?; | ||
|
||
if free_space < DESIRED_FREE_SPACE { | ||
println!("free space: not enough free space"); | ||
Ok(false) | ||
} else { | ||
println!("free space: enough free space"); | ||
Ok(true) | ||
} | ||
} | ||
|
||
fn parse_df_output(stdout: &str) -> Result<u64> { | ||
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 free_space = parts[3] | ||
.parse::<u64>() | ||
.context("parsing free space failed")?; | ||
|
||
Ok(free_space) | ||
} | ||
|
||
const EXPECTED_DOMAINS: [&str; 5] = [ | ||
// Ubuntu domains | ||
"archive.ubuntu.com", | ||
"security.ubuntu.com", | ||
// SecureDrop domains (including testing ones) | ||
"apt.freedom.press", | ||
"apt-qa.freedom.press", | ||
"apt-test.freedom.press", | ||
]; | ||
|
||
/// Verify only expected sources are configured for apt | ||
fn check_apt() -> Result<bool> { | ||
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 !EXPECTED_DOMAINS.contains(&domain) { | ||
println!("apt: unexpected source: {domain}"); | ||
return Ok(false); | ||
} | ||
} else { | ||
println!("apt: unexpected source: {uri}"); | ||
return Ok(false); | ||
} | ||
} | ||
} | ||
|
||
println!("apt: all sources are expected"); | ||
Ok(true) | ||
} | ||
|
||
/// Check that systemd has no failed units | ||
fn check_systemd() -> Result<bool> { | ||
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: some units are failed"); | ||
Ok(false) | ||
} else { | ||
println!("systemd: no failed units"); | ||
Ok(true) | ||
} | ||
} | ||
|
||
fn run() -> Result<()> { | ||
let codename = os_codename()?; | ||
if codename != "focal" { | ||
println!("Unsupported Ubuntu version: {codename}"); | ||
// nothing to do, write an empty JSON blob | ||
fs::write(STATE_PATH, "{}")?; | ||
return Ok(()); | ||
} | ||
|
||
let state = State { | ||
ssh: check_ssh_group()?, | ||
ufw: check_ufw_removed(), | ||
free_space: check_free_space()?, | ||
apt: check_apt()?, | ||
systemd: check_systemd()?, | ||
}; | ||
|
||
fs::write( | ||
STATE_PATH, | ||
serde_json::to_string(&state).context("serializing state failed")?, | ||
) | ||
.context("writing state file failed")?; | ||
if state.is_ready() { | ||
println!("All ready for migration!"); | ||
} else { | ||
println!( | ||
"Some errors were found that will block migration. | ||
If you are unsure what to do, please contact the SecureDrop | ||
support team: <https://docs.securedrop.org/en/stable/getting_support.html>." | ||
); | ||
// Logically we should exit with a failure here, but we don't | ||
// want the systemd unit to fail. | ||
} | ||
Ok(()) | ||
} | ||
|
||
fn main() -> Result<ExitCode> { | ||
if !geteuid().is_root() { | ||
println!("This script must be run as root"); | ||
return Ok(ExitCode::FAILURE); | ||
} | ||
|
||
match run() { | ||
Ok(()) => Ok(ExitCode::SUCCESS), | ||
Err(e) => { | ||
// Try to log the error in the least complex way possible | ||
fs::write(STATE_PATH, "{\"error\": true}")?; | ||
eprintln!("Error running migration pre-check: {e}"); | ||
Ok(ExitCode::FAILURE) | ||
} | ||
} | ||
} | ||
|
||
#[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() { | ||
// Taken from my Qubes VM, but the output of df is the same on Ubuntu | ||
assert_eq!(parse_df_output("Filesystem 1K-blocks Used Available Use% Mounted on | ||
/dev/mapper/dmroot 20260052 10727468 8478072 56% / | ||
").unwrap(), 8478072); | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
7 changes: 7 additions & 0 deletions
7
securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-check.service
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
[Unit] | ||
Description=Check noble migration readiness | ||
|
||
[Service] | ||
Type=oneshot | ||
ExecStart=/usr/bin/securedrop-noble-migration-check | ||
User=root |
10 changes: 10 additions & 0 deletions
10
securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-check.timer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
[Unit] | ||
Description=Check noble migration readiness | ||
|
||
[Timer] | ||
OnCalendar=hourly | ||
Persistent=true | ||
RandomizedDelaySec=5m | ||
|
||
[Install] | ||
WantedBy=timers.target |
Oops, something went wrong.