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 14, 2024
1 parent 47469ac commit b2408f6
Show file tree
Hide file tree
Showing 10 changed files with 1,378 additions and 81 deletions.
472 changes: 395 additions & 77 deletions Cargo.lock

Large diffs are not rendered by default.

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

import pytest
import testutils

test_vars = testutils.securedrop_test_vars
Expand Down Expand Up @@ -27,3 +30,20 @@ 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)

assert host.file("/etc/securedrop-noble-migration.json").exists
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"
205 changes: 205 additions & 0 deletions noble-migration/src/bin/check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! Check migration of a SecureDrop server from focal to noble
//!
//! This script is run as root on both the app and mon servers.
use anyhow::{anyhow, Result};
use rustix::process::geteuid;
use serde::Serialize;
use std::{
fs,
process::{self, ExitCode},
};
use url::Host;

const STATE_PATH: &str = "/etc/securedrop-noble-migration.json";

#[derive(Serialize)]
struct State {
ssh: bool,
ufw: bool,
free_space: bool,
apt: bool,
systemd: bool,
}

#[derive(Serialize)]
struct Error {
error: String,
}

/// Parse the OS codename from /etc/os-release
fn os_codename() -> Result<String> {
let contents = fs::read_to_string("/etc/os-release")?;
for line in contents.lines() {
if line.starts_with("VERSION_CODENAME=") {
let (_, codename) = line.split_once("=").unwrap();
return Ok(codename.trim().to_string());
}
}

Err(anyhow!(
"Could not find VERSION_CODENAME in /etc/os-release"
))
}

/// Check that the UNIX "ssh" group has no members
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()?;
if output.status.code() == Some(2) {
println!("ssh: group does not exist");
return Ok(true);
} else if !output.status.success() {
return Err(anyhow!("running getent failed",));
}

let stdout = String::from_utf8(output.stdout)?;
// The format looks like `ssh:x:123:member1,member2`
let (_, members) = stdout.rsplit_once(':').unwrap();
if members.is_empty() {
println!("ssh: group is empty");
Ok(true)
} else {
println!("ssh: group is not empty: {members}");
Ok(false)
}
}

/// Check that ufw is removed
fn check_ufw_removed() -> Result<bool> {
if fs::exists("/usr/sbin/ufw")? {
println!("ufw: ufw is still installed");
Ok(false)
} else {
println!("ufw: ufw was removed");
Ok(true)
}
}

/// Check that there is enough free space
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()?;
if !output.status.success() {
return Err(anyhow!("running df failed",));
}

let stdout = String::from_utf8(output.stdout)?;
let (_, line) = stdout.split_once('\n').unwrap();
let parts: Vec<_> = line.split_whitespace().collect();

let free_space = parts[3].parse::<u64>()?;
// Should be at least 10GB free
if free_space < 10 * 1024 * 1024 * 1024 {
println!("free space: not enough free space");
Ok(false)
} else {
println!("free space: enough free space");
Ok(true)
}
}

const EXPECTED_DOMAINS: [&str; 4] = [
"archive.ubuntu.com",
"security.ubuntu.com",
"apt.freedom.press",
"apt-test.freedom.press",
];

/// Verify only expected sources are configured
fn check_apt() -> Result<bool> {
let output = process::Command::new("apt-get")
.arg("indextargets")
.output()?;
if !output.status.success() {
return Err(anyhow!("running apt-get indextargets failed",));
}

let stdout = String::from_utf8(output.stdout)?;
for line in stdout.lines() {
if line.starts_with("URI:") {
let uri = line.strip_prefix("URI: ").unwrap();
let parsed = url::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)
}

fn check_systemd() -> Result<bool> {
let output = process::Command::new("systemctl")
.arg("is-failed")
.output()?;
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<ExitCode> {
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(ExitCode::SUCCESS);
}

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)?)?;
if state.ssh && state.ufw && state.free_space && state.apt && state.systemd
{
println!("All ready for migration!");
Ok(ExitCode::SUCCESS)
} 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>."
);
Ok(ExitCode::FAILURE)
}
}

fn main() -> Result<ExitCode> {
if !geteuid().is_root() {
return Err(anyhow!("This program must be run as root"));
}

match run() {
Ok(code) => Ok(code),
Err(e) => {
// Try to log the error
let serialized = serde_json::to_string(&Error {
error: e.to_string(),
})?;
fs::write(STATE_PATH, serialized)?;
eprintln!("Error: {e}");
Ok(ExitCode::FAILURE)
}
}
}
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
4 changes: 3 additions & 1 deletion securedrop/debian/rules
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ override_dh_auto_install:
cd /srv/rust/noble-migration && cargo build --release --locked && \
cd /srv/securedrop && \
mkdir -p ./debian/securedrop-config/usr/bin && \
mv /srv/rust/target/release/noble-migration ./debian/securedrop-config/usr/bin/noble-migration
mv /srv/rust/target/release/check ./debian/securedrop-config/usr/bin/securedrop-noble-migration-check
# Build redwood wheel
python3 /srv/rust/redwood/build-wheel.py --release --redwood /srv/rust/redwood --target /srv/rust/target
# Set up virtualenv and install dependencies
Expand Down Expand Up @@ -90,6 +90,7 @@ override_dh_systemd_enable:
dh_systemd_enable --no-enable securedrop-remove-pending-sources.service
dh_systemd_enable --no-enable securedrop-remove-packages.service
dh_systemd_enable --no-enable securedrop-cleanup-ossec.service
dh_systemd_enable --no-enable securedrop-noble-migration-check.service
dh_systemd_enable

# This is basically the same as the enable stanza above, just whether the
Expand All @@ -100,4 +101,5 @@ override_dh_systemd_start:
dh_systemd_start --no-start securedrop-remove-pending-sources.service
dh_systemd_start --no-start securedrop-remove-packages.service
dh_systemd_start --no-start securedrop-cleanup-ossec.service
dh_systemd_start --no-start securedrop-noble-migration-check.service
dh_systemd_start
Loading

0 comments on commit b2408f6

Please sign in to comment.