Skip to content

Commit

Permalink
Add a basic noble migration check script
Browse files Browse the repository at this point in the history
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
legoktm committed Nov 22, 2024
1 parent 105a171 commit e471c94
Show file tree
Hide file tree
Showing 10 changed files with 1,679 additions and 177 deletions.
472 changes: 395 additions & 77 deletions Cargo.lock

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions molecule/testinfra/common/test_release_upgrades.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import json
import time

import pytest
import testutils

test_vars = testutils.securedrop_test_vars
Expand Down Expand Up @@ -27,3 +31,35 @@ def test_release_manager_upgrade_channel(host):
_, channel = raw_output.split("=")

assert channel == "never"


def test_migration_check(host):
"""Verify our migration check script works"""
if host.system_info.codename != "focal":
pytest.skip("only applicable/testable on focal")

with host.sudo():
# remove state file so we can see if it works
if host.file("/etc/securedrop-noble-migration.json").exists:
host.run("rm /etc/securedrop-noble-migration.json")
cmd = host.run("systemctl start securedrop-noble-migration-check")
assert cmd.rc == 0
while host.service("securedrop-noble-migration-check").is_running:
time.sleep(1)

# JSON state file was created
assert host.file("/etc/securedrop-noble-migration.json").exists

cmd = host.run("cat /etc/securedrop-noble-migration.json")
assert cmd.rc == 0

contents = json.loads(cmd.stdout)
print(contents)
# The script did not error out
assert "error" not in contents
# staging CI jobs don't have enough free space, so just check
# that it returned a value for it
assert "free_space" in contents
del contents["free_space"]
# All the values should be True
assert all(contents.values())
5 changes: 5 additions & 0 deletions noble-migration/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.93"
rustix = { version = "0.38.40", features = ["process"] }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.132"
url = "2.5.3"
313 changes: 313 additions & 0 deletions noble-migration/src/bin/check.rs
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);
}
}
3 changes: 0 additions & 3 deletions noble-migration/src/main.rs

This file was deleted.

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
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
Loading

0 comments on commit e471c94

Please sign in to comment.