diff --git a/Cargo.lock b/Cargo.lock index 3e6b176c92..c99157d1c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,12 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bzip2" version = "0.4.4" @@ -263,6 +269,26 @@ dependencies = [ "log", ] +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -360,6 +386,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "iana-time-zone" version = "0.1.58" @@ -637,9 +669,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "memchr" @@ -682,6 +714,9 @@ name = "noble-migration" version = "0.1.0" dependencies = [ "anyhow", + "env_logger", + "log", + "rand", "rustix", "serde", "serde_json", @@ -796,6 +831,15 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -880,6 +924,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -1550,6 +1624,27 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "zerofrom" version = "0.1.4" diff --git a/noble-migration/Cargo.toml b/noble-migration/Cargo.toml index 23b92fd926..5c01ffc790 100644 --- a/noble-migration/Cargo.toml +++ b/noble-migration/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" [dependencies] anyhow = "1.0.93" +env_logger = { version = "0.11.5", features = ["humantime"] , default-features = false } +log = "0.4.22" +rand = "0.8.5" rustix = { version = "0.38.40", features = ["process"] } serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.132" diff --git a/noble-migration/files/apt_freedom_press.list b/noble-migration/files/apt_freedom_press.list new file mode 100644 index 0000000000..12e72a6778 --- /dev/null +++ b/noble-migration/files/apt_freedom_press.list @@ -0,0 +1 @@ +deb [arch=amd64] https://apt.freedom.press noble main diff --git a/noble-migration/files/sources.list b/noble-migration/files/sources.list new file mode 100644 index 0000000000..df96219dc8 --- /dev/null +++ b/noble-migration/files/sources.list @@ -0,0 +1,13 @@ +## newer versions of the distribution. +deb http://archive.ubuntu.com/ubuntu/ noble main + +## newer versions of the distribution. +deb http://archive.ubuntu.com/ubuntu/ noble universe + +## Major bug fix updates produced after the final release of the +## distribution. +deb http://archive.ubuntu.com/ubuntu/ noble-updates main + +### Security fixes for distribution packages +deb http://security.ubuntu.com/ubuntu noble-security main +deb http://security.ubuntu.com/ubuntu noble-security universe diff --git a/noble-migration/files/ubuntu.sources b/noble-migration/files/ubuntu.sources new file mode 100644 index 0000000000..bcd1f501fe --- /dev/null +++ b/noble-migration/files/ubuntu.sources @@ -0,0 +1,11 @@ +Types: deb +URIs: http://archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates +Components: main universe restricted multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu/ +Suites: noble-security +Components: main universe restricted multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg diff --git a/noble-migration/src/bin/upgrade.rs b/noble-migration/src/bin/upgrade.rs new file mode 100644 index 0000000000..f812b42e9b --- /dev/null +++ b/noble-migration/src/bin/upgrade.rs @@ -0,0 +1,667 @@ +//! Migrate a SecureDrop server from focal to noble +//! +//! This script should never be run directly, only via the +//! systemd service. +use anyhow::{bail, Context, Result}; +use log::{debug, error, info}; +use rand::{thread_rng, Rng}; +use rustix::process::geteuid; +use serde::{Deserialize, Serialize}; +use std::{ + env, + fs::{self, Permissions}, + os::unix::{ + fs::PermissionsExt, + process::{CommandExt, ExitStatusExt}, + }, + path::Path, + process::{self, Command, ExitCode}, +}; + +/// Package-provided instructions on whether auto-migrations should run +const CONFIG_PATH: &str = "/usr/share/securedrop/noble-upgrade.json"; +/// Serialized version of `State` +const STATE_PATH: &str = "/etc/securedrop-noble-migration-state.json"; +const MON_OSSEC_CONFIG: &str = "/var/ossec/etc/ossec.conf"; +/// Environment variable to allow developers to inject an extra APT source +const EXTRA_APT_SOURCE: &str = "EXTRA_APT_SOURCE"; + +/// All the different steps in the migration +/// +/// Each stage needs to be idempotent so that it can be run multiple times +/// in case it errors/crashes. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +enum Stage { + None, + PendingUpdates, + MigrationCheck, + DisableApache2, + Backup, + BackupIptables, + Marker, + SuspendOSSEC, + ChangeAptSources, + AptGetUpdate, + AptGetFullUpgrade, + AptGetAutoremove, + RestoreIptables, + ReenableUnattendedUpdates, + ReenableOSSEC, + CleanApacheFolder, + Reboot, + SwitchUbuntuSources, + IntegrityCheck, + ReenableApache2, + RemoveBackup, + Done, +} + +/// Current state of the migration. +#[derive(Serialize, Deserialize, Debug)] +struct State { + /// most recently finished stage. This is updated + /// on-disk as soon as every stage is completed. + finished: Stage, + /// randomly selected number between 1 and 5, which is used + /// to auto-migrate instances in chunks. This number is generated once, + /// when the service is first run. + bucket: usize, +} + +impl State { + /// Load the State file from disk, or create it if it doesn't exist + /// + /// This is run on every invocation of the script, even if auto-migration + /// isn't enabled yet. This allows the bucket to be generated only once. + fn load() -> Result { + if !Path::new(STATE_PATH).exists() { + debug!("State file, {}, doesn't exist; state=None", STATE_PATH); + let mut rng = thread_rng(); + let state = State { + finished: Stage::None, + bucket: rng.gen_range(1..=5), + }; + // Persist the randomly selected bucket + state.save()?; + return Ok(state); + } + debug!("Loading state from {}", STATE_PATH); + // If this fails we're basically stuck. + let state = serde_json::from_str( + &fs::read_to_string(STATE_PATH) + .context("Unable to read STATE_PATH")?, + ) + .context("Deserializing STATE_PATH failed")?; + debug!("Loaded state: {state:?}"); + Ok(state) + } + + /// Set the specified stage as finished and persist to disk + fn set(&mut self, stage: Stage) -> Result<()> { + debug!("Finished stage {stage:?}"); + self.finished = stage; + self.save() + } + + /// Persist current state to disk + fn save(&self) -> Result<()> { + fs::write( + STATE_PATH, + serde_json::to_string(self).context("Failed to serialize state")?, + ) + .context("Failed to write state") + } +} + +/// State machine to invoke the next stage based on what has finished. +fn run_next_stage(state: &mut State) -> Result<()> { + match state.finished { + Stage::None => { + pending_updates(state)?; + // n.b. this is unreachable because we've already rebooted + state.set(Stage::PendingUpdates)?; + } + Stage::PendingUpdates => { + migration_check()?; + state.set(Stage::MigrationCheck)?; + } + Stage::MigrationCheck => { + disable_apache2()?; + state.set(Stage::DisableApache2)?; + } + Stage::DisableApache2 => { + backup()?; + state.set(Stage::Backup)?; + } + Stage::Backup => { + backup_iptables()?; + state.set(Stage::BackupIptables)?; + } + Stage::BackupIptables => { + marker()?; + state.set(Stage::Marker)?; + } + Stage::Marker => { + suspend_ossec()?; + state.set(Stage::SuspendOSSEC)?; + } + Stage::SuspendOSSEC => { + change_apt_sources()?; + state.set(Stage::ChangeAptSources)?; + } + Stage::ChangeAptSources => { + apt_get_update()?; + state.set(Stage::AptGetUpdate)?; + } + Stage::AptGetUpdate => { + apt_get_full_upgrade()?; + state.set(Stage::AptGetFullUpgrade)?; + } + Stage::AptGetFullUpgrade => { + apt_get_autoremove()?; + state.set(Stage::AptGetAutoremove)?; + } + Stage::AptGetAutoremove => { + restore_iptables()?; + state.set(Stage::RestoreIptables)?; + } + Stage::RestoreIptables => { + reenable_unattended_updates()?; + state.set(Stage::ReenableUnattendedUpdates)?; + } + Stage::ReenableUnattendedUpdates => { + reenable_ossec()?; + state.set(Stage::ReenableOSSEC)?; + } + Stage::ReenableOSSEC => { + clean_apache_folder()?; + state.set(Stage::CleanApacheFolder)?; + } + Stage::CleanApacheFolder => { + reboot(state)?; + // n.b. this is unreachable because we've already rebooted + state.set(Stage::Reboot)?; + } + Stage::Reboot => { + switch_ubuntu_sources()?; + state.set(Stage::SwitchUbuntuSources)?; + } + Stage::SwitchUbuntuSources => { + integrity_check()?; + state.set(Stage::IntegrityCheck)?; + } + Stage::IntegrityCheck => { + reenable_apache2()?; + state.set(Stage::ReenableApache2)?; + } + Stage::ReenableApache2 => { + remove_backup()?; + state.set(Stage::RemoveBackup)?; + } + Stage::RemoveBackup => { + state.set(Stage::Done)?; + } + Stage::Done => {} + } + Ok(()) +} + +/// A wrapper to roughly implement Python's subprocess.check_call/check_output +fn check_call(binary: &str, args: &[&str]) -> Result { + debug!("Running: {binary} {}", args.join(" ")); + let output = Command::new(binary) + .args(args) + // Always set apt's non-interactive mode + .env("DEBIAN_FRONTEND", "noninteractive") + .output() + .context(format!("failed to spawn/execute '{binary}'"))?; + if output.status.success() { + debug!("Finished running: {binary} {}", args.join(" ")); + // In theory we could use from_utf8_lossy here as we're not expecting + // any non-UTF-8 output, but let's error in that case. + let stdout = String::from_utf8(output.stdout) + .context("stdout contains non-utf8 bytes")?; + debug!("{stdout}"); + Ok(stdout) + } else { + debug!("Errored running: {binary} {}", args.join(" ")); + // Figure out why it failed by looking at the exit code, and if none, + // look at if it was a signal + let exit = match output.status.code() { + Some(code) => format!("exit code {code}"), + None => match output.status.signal() { + Some(signal) => format!("terminated by signal {signal}"), + None => "for an unknown reason".to_string(), + }, + }; + error!("{}", String::from_utf8_lossy(&output.stderr)); + bail!("running '{binary}' failed; {exit}") + } +} + +/// Roughly the same as `check_call`, but run the command in a way that it +/// will keep running even when this script is killed. This is necessary +/// to keep apt-get from getting killed when the systemd service is restarted. +fn check_call_nokill(binary: &str, args: &[&str]) -> Result<()> { + let child = Command::new(binary) + .args(args) + .env("DEBIAN_FRONTEND", "noninteractive") + // Run this in a separate process_group, so it won't be killed when + // the parent process is (this script). + .process_group(0) + // Let stdout/stderr to the parent; journald will pick it up + .spawn() + .context(format!("failed to spawn '{binary}'"))?; + + let output = child.wait_with_output()?; + if !output.status.success() { + debug!("Errored running: {binary} {}", args.join(" ")); + // Figure out why it failed by looking at the exit code, and if none, + // look at if it was a signal + let exit = match output.status.code() { + Some(code) => format!("exit code {code}"), + None => match output.status.signal() { + Some(signal) => format!("terminated by signal {signal}"), + None => "for an unknown reason".to_string(), + }, + }; + error!("{}", String::from_utf8_lossy(&output.stderr)); + bail!("running '{binary}' failed; {exit}") + } + + Ok(()) +} + +/// Check if the current server is the mon server by +/// looking for the securedrop-ossec-server package +fn is_mon_server() -> bool { + Path::new("/usr/share/doc/securedrop-ossec-server/copyright").exists() +} + +/// Step 1: Apply any pending updates +/// +/// Explicitly run unattended-upgrade, then disable it and reboot +fn pending_updates(state: &mut State) -> Result<()> { + info!("Applying any pending updates..."); + check_call("apt-get", &["update"])?; + check_call_nokill("unattended-upgrade", &[])?; + // Disable all background updates pre-reboot so we know it's fully + // disabled when we come back. + info!("Temporarily disabling background updates..."); + check_call("systemctl", &["mask", "unattended-upgrades"])?; + check_call("systemctl", &["mask", "apt-daily"])?; + check_call("systemctl", &["mask", "apt-daily-upgrade"])?; + state.set(Stage::PendingUpdates)?; + check_call("systemctl", &["reboot"])?; + // Because we've initiated the reboot, do a hard stop here to ensure that + // we don't keep moving forward if the reboot doesn't happen instantly + process::exit(0); +} + +/// Step 2: Run the migration check +/// +/// Run the same migration check as a final verification step before +/// we begin +fn migration_check() -> Result<()> { + info!("Checking pre-migration steps..."); + if noble_migration::os_codename()? != "focal" { + bail!("not a focal system"); + } + let state = + noble_migration::run_checks().context("migration check errored")?; + + if env::var(EXTRA_APT_SOURCE).is_ok() { + // If we're injecting an extra APT source, then allow that check to fail + if !state.is_ready_except_apt() { + bail!("Migration check failed") + } + } else if !state.is_ready() { + bail!("Migration check failed") + } + + Ok(()) +} + +/// Step 3: Disable apache2 +/// +/// On the app server, disable apache for the duration of the upgrade to prevent +/// any modifications to the SecureDrop database/state +fn disable_apache2() -> Result<()> { + if is_mon_server() { + return Ok(()); + } + info!("Stopping web server for duration of upgrade..."); + check_call("systemctl", &["mask", "apache2"])?; + Ok(()) +} + +/// Step 4: Take a backup +/// +/// On the app server, run the normal backup script for disaster recovery +/// in case something does go wrong +fn backup() -> Result<()> { + if is_mon_server() { + return Ok(()); + } + info!("Taking a backup..."); + // Create a root-only directory to store the backup + if !Path::new("/var/lib/securedrop-backups").exists() { + fs::create_dir("/var/lib/securedrop-backups")? + } + let permissions = Permissions::from_mode(0o700); + fs::set_permissions("/var/lib/securedrop-backups", permissions)?; + check_call( + "/usr/bin/securedrop-app-backup.py", + &["--dest", "/var/lib/securedrop-backups"], + )?; + Ok(()) +} + +/// Step 5: Backup the iptables rules +/// +/// During the iptables-persistent upgrade, the iptables rules get wiped. Because +/// these are generated by ansible, back up them up ahead of time and then we'll +/// restore them after the upgrade +fn backup_iptables() -> Result<()> { + info!("Backing up iptables..."); + fs::copy("/etc/iptables/rules.v4", "/etc/iptables/rules.v4.backup")?; + fs::copy("/etc/iptables/rules.v6", "/etc/iptables/rules.v6.backup")?; + Ok(()) +} + +/// Step 6: Write an upgrade marker file +/// +/// Write a marker file to indicate that we've upgraded from focal. There is no +/// use for this file right now, but if we discover some sort of variance in the +/// future, we can look for the existence of this file to conditionally guard +/// actions based on it. +fn marker() -> Result<()> { + info!("Writing upgrade marker file..."); + fs::write("/etc/securedrop-upgraded-from-focal", "yes") + .context("failed to write upgrade marker file") +} + +/// Step 7: Suspend OSSEC notifications +/// +/// On the mon server, raise the OSSEC alert level to prevent a bajillion +/// notifications from being sent. +fn suspend_ossec() -> Result<()> { + if !is_mon_server() { + return Ok(()); + } + info!("Temporarily suspending most OSSEC notifications..."); + let current = fs::read_to_string(MON_OSSEC_CONFIG)?; + let new = current.replace( + "7", + "15", + ); + fs::write(MON_OSSEC_CONFIG, new)?; + check_call("systemctl", &["restart", "ossec"])?; + Ok(()) +} + +/// Step 8: Switch APT sources +/// +/// Update all the APT sources to use noble. Developers can set +/// EXTRA_APT_SOURCE in the systemd unit to inject an extra APT source +/// (e.g. apt-qa). +fn change_apt_sources() -> Result<()> { + info!("Switching APT sources to noble..."); + fs::write( + "/etc/apt/sources.list", + include_str!("../../files/sources.list"), + )?; + let mut contents = + include_str!("../../files/apt_freedom_press.list").to_string(); + // Allow developers to inject an extra APT source + if let Ok(extra) = env::var(EXTRA_APT_SOURCE) { + contents.push_str(format!("\n{extra}\n").as_str()); + } + fs::write("/etc/apt/sources.list.d/apt_freedom_press.list", contents)?; + Ok(()) +} + +/// Step 9: Update APT cache +/// +/// Standard apt-get update +fn apt_get_update() -> Result<()> { + info!("Updating APT cache..."); + check_call("apt-get", &["update"])?; + Ok(()) +} + +/// Step 10: Upgrade APT packages +/// +/// Actually do the upgrade! We pass --force-confold to tell dpkg +/// to always keep the old config files if they've been modified (by us). +/// +/// We use the nokill invocation because otherwise when the securedrop-config +/// package is upgraded, it'll kill this script and apt-get. In case we are killed, +/// apt-get will keep going. We'll rely on apt/dpkg's locking mechanism to prevent +/// duplicate processes. +/// +/// This command is idempotent since running it after the upgrade is done will just +/// upgrade no packages. +/// +/// Theoretically once this step finishes, we're on a noble system. +fn apt_get_full_upgrade() -> Result<()> { + info!("Upgrading APT packages..."); + check_call_nokill( + "apt-get", + &[ + "-o", + "Dpkg::Options::=--force-confold", + "full-upgrade", + "--yes", + ], + )?; + Ok(()) +} + +/// Step 11: Remove removable APT packages +/// +/// Standard apt-get autoremove. +fn apt_get_autoremove() -> Result<()> { + info!("Removing removable APT packages..."); + check_call_nokill("apt-get", &["autoremove", "--yes"])?; + Ok(()) +} + +/// Step 12: Restore iptables rules +/// +/// Now that we've upgraded, move the backups of the iptables rules we created +/// into place and clean up the backups. +fn restore_iptables() -> Result<()> { + info!("Restoring iptables..."); + fs::copy("/etc/iptables/rules.v4.backup", "/etc/iptables/rules.v4")?; + fs::copy("/etc/iptables/rules.v6.backup", "/etc/iptables/rules.v6")?; + fs::remove_file("/etc/iptables/rules.v4.backup")?; + fs::remove_file("/etc/iptables/rules.v6.backup")?; + Ok(()) +} + +/// Step 13: Re-enable unattended-updates +/// +/// Re-enable all the unattended-upgrades units we disabled earlier. +fn reenable_unattended_updates() -> Result<()> { + info!("Re-enabling background updates..."); + check_call("systemctl", &["unmask", "unattended-upgrades"])?; + check_call("systemctl", &["unmask", "apt-daily"])?; + check_call("systemctl", &["unmask", "apt-daily-upgrade"])?; + Ok(()) +} + +/// Step 14: Re-enable OSSEC notifications +/// +/// Undo our suspending of OSSEC notifications. +fn reenable_ossec() -> Result<()> { + if !is_mon_server() { + return Ok(()); + } + info!("Re-enabling OSSEC notifications..."); + let current = fs::read_to_string(MON_OSSEC_CONFIG)?; + let new = current.replace( + "15", + "7", + ); + fs::write(MON_OSSEC_CONFIG, new)?; + check_call("systemctl", &["restart", "ossec"])?; + Ok(()) +} + +/// Step 15: Clean /var/www/html folder +/// +/// We delete this with ansible, but the upgrade brings it back (but empty) +/// so remove it. +fn clean_apache_folder() -> Result<()> { + info!("Cleaning /var/www/html folder..."); + fs::remove_dir("/var/www/html")?; + Ok(()) +} + +/// Step 16: Reboot +/// +/// Reboot! +fn reboot(state: &mut State) -> Result<()> { + info!("Rebooting!"); + state.set(Stage::Reboot)?; + check_call("systemctl", &["reboot"])?; + // Because we've initiated the reboot, do a hard stop here to ensure that + // we don't keep moving forward if the reboot doesn't happen instantly + process::exit(0); +} + +/// Step 17: Switch APT sources format +/// +/// Switch to the new APT deb822 sources format for ubuntu.sources +/// to mirror how the noble installer generates it. +/// +/// We cannot do this earlier because focal's apt doesn't understand it. +fn switch_ubuntu_sources() -> Result<()> { + info!("Switching APT sources format..."); + fs::write( + "/etc/apt/sources.list.d/ubuntu.sources", + include_str!("../../files/ubuntu.sources"), + ) + .context("failed to write ubuntu.sources")?; + fs::remove_file("/etc/apt/sources.list") + .context("failed to remove sources.list")?; + // Verify APT is happy with the new file + check_call("apt-get", &["update"])?; + Ok(()) +} + +/// Step 18: Integrity check +/// +/// Before we turn the system back on, check that nothing is obviously wrong +fn integrity_check() -> Result<()> { + info!("Running integrity check post-upgrade..."); + // Check systemd units are happy + if !noble_migration::check_systemd()? { + bail!("some systemd units are not happy"); + } + // Very simple check that the iptables firewall is up + let iptables = check_call("iptables", &["-S"])?; + if !iptables.contains("INPUT DROP") { + bail!("iptables firewall is not up"); + } + Ok(()) +} + +/// Step 19: Re-enable Apache +/// +/// Fire up app's web server now that we're done +fn reenable_apache2() -> Result<()> { + if is_mon_server() { + return Ok(()); + } + info!("Starting web server..."); + check_call("systemctl", &["unmask", "apache2"])?; + check_call("systemctl", &["start", "apache2"])?; + Ok(()) +} + +/// Step 20: Remove backup +/// +/// Now that we've finished, remove the backup we created earlier. +fn remove_backup() -> Result<()> { + if is_mon_server() { + return Ok(()); + } + info!("Deleting backup..."); + fs::remove_dir_all("/var/lib/securedrop-backups")?; + Ok(()) +} + +#[derive(Deserialize)] +struct UpgradeConfig { + app: HostUpgradeConfig, + mon: HostUpgradeConfig, +} + +#[derive(Deserialize)] +struct HostUpgradeConfig { + /// whether upgrades are enabled + enabled: bool, + /// all servers <= this bucket will be upgraded, i.e. 1 upgrades bucket 1, + /// 2 upgrades buckets 1 and 2, etc. + bucket: usize, +} + +fn should_upgrade(state: &State) -> Result { + let config: UpgradeConfig = serde_json::from_str( + &fs::read_to_string(CONFIG_PATH) + .context("failed to read CONFIG_PATH")?, + ) + .context("failed to deserialize CONFIG_PATH")?; + // If we've already started the upgrade, keep going regardless of config + if state.finished != Stage::None { + info!("Upgrade has already started; will keep going"); + return Ok(true); + } + let (for_host, host_name) = if is_mon_server() { + (&config.mon, "mon") + } else { + (&config.app, "app") + }; + if !for_host.enabled { + info!("Auto-upgrades are disabled ({host_name})"); + return Ok(false); + } + if for_host.bucket < state.bucket { + info!( + "Auto-upgrades are enabled ({host_name}), but our bucket hasn't been enabled yet" + ); + return Ok(false); + } + + Ok(true) +} + +fn main() -> Result { + env_logger::init(); + + if !geteuid().is_root() { + error!("This script must be run as root"); + return Ok(ExitCode::FAILURE); + } + + if env::var("LAUNCHED_BY_SYSTEMD").is_err() { + error!("This script must be run from the systemd unit"); + return Ok(ExitCode::FAILURE); + } + + let mut state = State::load()?; + if !should_upgrade(&state)? { + return Ok(ExitCode::SUCCESS); + } + info!("Starting migration from state: {:?}", state.finished); + loop { + run_next_stage(&mut state)?; + if state.finished == Stage::Done { + break; + } + } + + Ok(ExitCode::SUCCESS) +} diff --git a/securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-upgrade.service b/securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-upgrade.service new file mode 100644 index 0000000000..0eeab11e16 --- /dev/null +++ b/securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-upgrade.service @@ -0,0 +1,12 @@ +[Unit] +Description=Run noble migration + +RefuseManualStop=true + +[Service] +Type=exec +Environment=RUST_LOG=debug +Environment=LAUNCHED_BY_SYSTEMD=1 +ExecStart=/usr/bin/securedrop-noble-migration-upgrade +User=root +KillMode=process diff --git a/securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-upgrade.timer b/securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-upgrade.timer new file mode 100644 index 0000000000..2f5892d34d --- /dev/null +++ b/securedrop/debian/config/lib/systemd/system/securedrop-noble-migration-upgrade.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run noble migration + +[Timer] +OnBootSec=3m +OnUnitInactiveSec=3m +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/securedrop/debian/config/usr/share/securedrop/noble-upgrade.json b/securedrop/debian/config/usr/share/securedrop/noble-upgrade.json new file mode 100644 index 0000000000..4c0a630183 --- /dev/null +++ b/securedrop/debian/config/usr/share/securedrop/noble-upgrade.json @@ -0,0 +1,10 @@ +{ + "app": { + "enabled": false, + "bucket": 0 + }, + "mon": { + "enabled": false, + "bucket": 0 + } +} diff --git a/securedrop/debian/rules b/securedrop/debian/rules index 548660c328..28032fc191 100755 --- a/securedrop/debian/rules +++ b/securedrop/debian/rules @@ -22,7 +22,8 @@ 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/check ./debian/securedrop-config/usr/bin/securedrop-noble-migration-check + mv /srv/rust/target/release/check ./debian/securedrop-config/usr/bin/securedrop-noble-migration-check && \ + mv /srv/rust/target/release/upgrade ./debian/securedrop-config/usr/bin/securedrop-noble-migration-upgrade # 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 @@ -92,6 +93,7 @@ override_dh_systemd_enable: dh_systemd_enable --no-enable securedrop-cleanup-ossec.service dh_systemd_enable --no-enable securedrop-reboot-required.service dh_systemd_enable --no-enable securedrop-noble-migration-check.service + dh_systemd_enable --no-enable securedrop-noble-migration-upgrade.service dh_systemd_enable # This is basically the same as the enable stanza above, just whether the @@ -104,4 +106,6 @@ override_dh_systemd_start: dh_systemd_start --no-start securedrop-cleanup-ossec.service dh_systemd_start --no-start securedrop-reboot-required.service dh_systemd_start --no-start securedrop-noble-migration-check.service + dh_systemd_start --no-start --no-restart-after-upgrade \ + securedrop-noble-migration-upgrade.service dh_systemd_start diff --git a/securedrop/debian/securedrop-app-code.postinst b/securedrop/debian/securedrop-app-code.postinst index e74d00dd5c..1a96d24811 100644 --- a/securedrop/debian/securedrop-app-code.postinst +++ b/securedrop/debian/securedrop-app-code.postinst @@ -311,8 +311,14 @@ case "$1" in database_migration # Restart apache now that we've updated everything, setup AppArmor - # and applied all migrations - service apache2 restart + # and applied all migrations. Only restart if it is not masked, which + # it is during the noble migration. + apache2_status=$(systemctl is-enabled apache2 2>/dev/null ||:) + if [ "$apache2_status" != "masked" ]; then + systemctl restart apache2 + else + echo "apache2 is masked, skipping restart" + fi ;; diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml index a420308c26..876a482cca 100644 --- a/supply-chain/audits.toml +++ b/supply-chain/audits.toml @@ -300,6 +300,20 @@ start = "2019-03-19" end = "2024-05-02" notes = "Rust Project member" +[[trusted.env_filter]] +criteria = "safe-to-deploy" +user-id = 6743 # Ed Page (epage) +start = "2024-01-19" +end = "2025-06-02" +notes = "Rust Project member" + +[[trusted.env_logger]] +criteria = "safe-to-deploy" +user-id = 6743 # Ed Page (epage) +start = "2022-11-24" +end = "2025-06-02" +notes = "Rust Project member" + [[trusted.equivalent]] criteria = "safe-to-deploy" user-id = 539 # Josh Stone (cuviper) diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock index ce168f3c3e..eaa22c75af 100644 --- a/supply-chain/imports.lock +++ b/supply-chain/imports.lock @@ -64,6 +64,20 @@ user-id = 539 user-login = "cuviper" user-name = "Josh Stone" +[[publisher.env_filter]] +version = "0.1.2" +when = "2024-07-25" +user-id = 6743 +user-login = "epage" +user-name = "Ed Page" + +[[publisher.env_logger]] +version = "0.11.5" +when = "2024-07-25" +user-id = 6743 +user-login = "epage" +user-name = "Ed Page" + [[publisher.equivalent]] version = "1.0.1" when = "2023-07-10" @@ -448,6 +462,13 @@ criteria = "safe-to-run" delta = "2.3.2 -> 2.4.0" aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" +[[audits.google.audits.byteorder]] +who = "danakj " +criteria = "safe-to-deploy" +version = "1.5.0" +notes = "Unsafe review in https://crrev.com/c/5838022" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + [[audits.google.audits.cc]] who = "George Burgess IV " criteria = "safe-to-run" @@ -520,6 +541,12 @@ crypto implementations. Hence, this crate does not implement crypto. """ aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" +[[audits.google.audits.humantime]] +who = "George Burgess IV " +criteria = "safe-to-run" +version = "2.1.0" +aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" + [[audits.google.audits.itertools]] who = "ChromeOS" criteria = "safe-to-run" @@ -562,16 +589,16 @@ version = "1.4.0" aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" [[audits.google.audits.log]] -who = "ChromeOS" -criteria = "safe-to-run" -version = "0.4.17" -aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" +who = "danakj " +criteria = "safe-to-deploy" +version = "0.4.22" +notes = """ +Unsafe review in https://docs.google.com/document/d/1IXQbD1GhTRqNHIGxq6yy7qHqxeO4CwN5noMFXnqyDIM/edit?usp=sharing -[[audits.google.audits.log]] -who = "George Burgess IV " -criteria = "safe-to-run" -delta = "0.4.17 -> 0.4.20" -aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" +Unsafety is generally very well-documented, with one exception, which we +describe in the review doc. +""" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" [[audits.google.audits.memoffset]] who = "Dennis Kempin " @@ -609,6 +636,24 @@ criteria = "safe-to-run" version = "0.3.26" aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" +[[audits.google.audits.ppv-lite86]] +who = "danakj@chromium.org" +criteria = "safe-to-run" +version = "0.2.17" +notes = """ +Reviewed in https://crrev.com/c/5171063 + +Previously reviewed during security review and the audit is grandparented in. +""" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.ppv-lite86]] +who = "danakj " +criteria = "safe-to-run" +delta = "0.2.17 -> 0.2.20" +notes = "Using zerocopy to reduce unsafe usage." +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + [[audits.google.audits.proc-macro2]] who = "Adrian Taylor " criteria = "safe-to-deploy" @@ -704,6 +749,29 @@ The delta just 1) inlines/expands `impl ToTokens` that used to be handled via """ aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" +[[audits.google.audits.rand]] +who = "danakj@chromium.org" +criteria = "safe-to-run" +version = "0.8.5" +notes = """ +Reviewed in https://crrev.com/c/5171063 + +Previously reviewed during security review and the audit is grandparented in. +""" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.rand_chacha]] +who = "Android Legacy" +criteria = "safe-to-run" +version = "0.3.1" +aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" + +[[audits.google.audits.rand_core]] +who = "Android Legacy" +criteria = "safe-to-run" +version = "0.6.4" +aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" + [[audits.google.audits.same-file]] who = "Android Legacy" criteria = "safe-to-run" @@ -1421,7 +1489,7 @@ aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-ch [[audits.mozilla.audits.url]] who = "Valentin Gosu " criteria = "safe-to-deploy" -delta = "2.5.1 -> 2.5.3" +delta = "2.5.1 -> 2.5.4" aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" [[audits.mozilla.audits.utf16_iter]] @@ -1488,6 +1556,26 @@ criteria = "safe-to-deploy" delta = "0.7.3 -> 0.7.4" aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" +[[audits.mozilla.audits.zerocopy]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +version = "0.7.32" +notes = """ +This crate is `no_std` so doesn't use any side-effectful std functions. It +contains quite a lot of `unsafe` code, however. I verified portions of this. It +also has a large, thorough test suite. The project claims to run tests with +Miri to have stronger soundness checks, and also claims to use formal +verification tools to prove correctness. +""" +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.mozilla.audits.zerocopy-derive]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +version = "0.7.32" +notes = "Clean, safe macros for zerocopy." +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + [[audits.mozilla.audits.zerofrom]] who = "Makoto Kato " criteria = "safe-to-deploy" @@ -1580,3 +1668,15 @@ who = "Jack Grigg " criteria = "safe-to-deploy" delta = "1.16.0 -> 1.17.0" aggregated-from = "https://raw.githubusercontent.com/zcash/zcash/master/qa/supply-chain/audits.toml" + +[[audits.zcash.audits.zerocopy]] +who = "Daira-Emma Hopwood " +criteria = "safe-to-deploy" +delta = "0.7.32 -> 0.7.34" +aggregated-from = "https://raw.githubusercontent.com/zcash/librustzcash/main/supply-chain/audits.toml" + +[[audits.zcash.audits.zerocopy-derive]] +who = "Daira-Emma Hopwood " +criteria = "safe-to-deploy" +delta = "0.7.32 -> 0.7.34" +aggregated-from = "https://raw.githubusercontent.com/zcash/librustzcash/main/supply-chain/audits.toml"