sdw_updater/Updater.py (351 lines of code) (raw):

""" Utility library for checking and applying SecureDrop Workstation VM updates. This library is meant to be called by the SecureDrop launcher, which is opened by the user when clicking on the desktop, opening /usr/bin/sdw-updater. """ import json import os import subprocess import threading import time from datetime import datetime, timedelta from enum import Enum from sdw_util import Util DATE_FORMAT = "%Y-%m-%d %H:%M:%S" DEFAULT_HOME = ".securedrop_updater" FLAG_FILE_STATUS_SD_APP = "/home/user/.securedrop_client/sdw-update-status" FLAG_FILE_LAST_UPDATED_SD_APP = "/home/user/.securedrop_client/sdw-last-updated" FLAG_FILE_STATUS_DOM0 = os.path.join(DEFAULT_HOME, "sdw-update-status") FLAG_FILE_LAST_UPDATED_DOM0 = os.path.join(DEFAULT_HOME, "sdw-last-updated") LOCK_FILE = "sdw-updater.lock" LOG_FILE = "updater.log" DETAIL_LOG_FILE = "updater-detail.log" DETAIL_LOGGER_PREFIX = "detail" # For detailed logs such as Salt states # We use a hardcoded temporary directory path in dom0. As dom0 is not # a multi-user environment, we can safely assume that only the Updater is # managing that filepath. Later on, we should consider porting the check-migration # logic to leverage the Qubes Python API. MIGRATION_DIR = "/tmp/sdw-migrations" DEBIAN_VERSION = "bookworm" sdlog = Util.get_logger(module=__name__) detail_log = Util.get_logger(prefix=DETAIL_LOGGER_PREFIX, module=__name__) # The are the TemplateVMs that require full patch level at boot in order to start the client, # as well as their associated TemplateVMs. # In the future, we could use qvm-prefs to extract this information. current_vms = { "fedora": "fedora-40-xfce", "sd-viewer": f"sd-large-{DEBIAN_VERSION}-template", "sd-app": f"sd-small-{DEBIAN_VERSION}-template", "sd-log": f"sd-small-{DEBIAN_VERSION}-template", "sd-devices": f"sd-large-{DEBIAN_VERSION}-template", "sd-proxy": f"sd-small-{DEBIAN_VERSION}-template", "sd-whonix": "whonix-gateway-17", "sd-gpg": f"sd-small-{DEBIAN_VERSION}-template", } current_templates = set([val for key, val in current_vms.items() if key != "dom0"]) def get_dom0_path(folder): return os.path.join(os.path.expanduser("~"), folder) def run_full_install(): """ Re-apply the entire Salt config via sdw-admin. Required to enforce VM state during major migrations, such as template consolidation. """ sdlog.info("Running 'sdw-admin --apply' to apply full system state") apply_cmd = ["sdw-admin", "--apply"] apply_cmd_for_log = (" ").join(apply_cmd) try: output = subprocess.check_output(apply_cmd) except subprocess.CalledProcessError as e: sdlog.error(f"Failed to apply full system state. Please review {DETAIL_LOG_FILE}.") sdlog.error(str(e)) clean_output = Util.strip_ansi_colors(e.output.decode("utf-8").strip()) detail_log.error(f"Output from failed command: {apply_cmd_for_log}\n{clean_output}") return UpdateStatus.UPDATES_FAILED clean_output = Util.strip_ansi_colors(output.decode("utf-8").strip()) detail_log.info(f"Output from command: {apply_cmd_for_log}\n{clean_output}") # Clean up flag requesting migration. Shell out since root created it. rm_flag_cmd = ["sudo", "rm", "-rf", MIGRATION_DIR] try: subprocess.check_call(rm_flag_cmd) except subprocess.CalledProcessError as e: sdlog.error("Failed to remove migration flag.") sdlog.error(str(e)) return UpdateStatus.UPDATES_FAILED sdlog.info("Full system state successfully applied and migration flag cleared.") return UpdateStatus.UPDATES_OK def migration_is_required(): """ Check whether a full run of the Salt config via sdw-admin is required. """ result = False if os.path.exists(MIGRATION_DIR) and len(os.listdir(MIGRATION_DIR)) > 0: sdlog.info("Migration is required, will enforce full config during update") result = True return result def apply_updates_dom0(): """ Apply updates to dom0 """ sdlog.info("Applying all updates to dom0") dom0_status = _check_updates_dom0() if dom0_status == UpdateStatus.UPDATES_REQUIRED: upgrade_results = _apply_updates_dom0() else: upgrade_results = UpdateStatus.UPDATES_OK return upgrade_results def apply_updates_templates(templates=current_templates, progress_callback=None): """ Apply updates to all TemplateVMs. """ sdlog.info(f"Applying all updates to VMs: {', '.join(templates)}") try: proc = _start_qubes_updater_proc(templates) result_update_status = {} stderr_thread = threading.Thread( target=_qubes_updater_parse_progress, args=(proc.stderr, result_update_status, templates, progress_callback), ) stdout_thread = threading.Thread(target=_qubes_updater_parse_stdout, args=(proc.stdout,)) stderr_thread.start() stdout_thread.start() while proc.poll() is None or stderr_thread.is_alive() or stdout_thread.is_alive(): time.sleep(1) stderr_thread.join() stdout_thread.join() proc.stderr.close() proc.stdout.close() update_status = overall_update_status(result_update_status) if update_status == UpdateStatus.UPDATES_OK: sdlog.info("Template updates successful") else: sdlog.info("Template updates failed") return update_status except subprocess.CalledProcessError as e: sdlog.error( "An error has occurred updating templates. Please contact your administrator." f" See {DETAIL_LOG_FILE} for details." ) sdlog.error(str(e)) return UpdateStatus.UPDATES_FAILED def _start_qubes_updater_proc(templates): update_cmd = [ "qubes-vm-update", "--apply-to-all", # Enforce app qube restarts "--force-update", # Bypass Qubes' staleness-dection and update all "--show-output", # Update transaction details (goes to stdout) "--just-print-progress", # Progress reporting (goes to stderr) "--targets", ",".join(templates), ] detail_log.info("Starting Qubes Updater with command: {}".format(" ".join(update_cmd))) return subprocess.Popen( update_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) def _qubes_updater_parse_stdout(stream): while True: untrusted_line = stream.readline() if len(untrusted_line) == 0: break line = Util.strip_ansi_colors(untrusted_line.decode("utf-8")) line = line.rstrip() detail_log.info(f"[Qubes updater] {line}") def _qubes_updater_parse_progress(stream, result, templates, progress_callback=None): update_progress = {} for template in templates: result[template] = UpdateStatus.UPDATES_IN_PROGRESS while True: untrusted_line = stream.readline() if len(untrusted_line) == 0: break line = Util.strip_ansi_colors(untrusted_line.decode("utf-8").rstrip()) try: vm, status, info = line.split() except ValueError: sdlog.warn("Line in Qubes updater's output could not be parsed") continue if status == "updating": if update_progress.get(vm) is None: sdlog.info(f"Starting update on template: '{vm}'") update_progress[vm] = 0 else: vm_progress = int(float(info)) update_progress[vm] = vm_progress if progress_callback: progress_callback(sum(update_progress.values()) // len(templates)) # First time complete (status "done") may be repeated various times if status == "done" and result[vm] == UpdateStatus.UPDATES_IN_PROGRESS: result[vm] = UpdateStatus.from_qubes_updater_name(info) if result[vm] == UpdateStatus.UPDATES_OK: sdlog.info(f"Update successful for template: '{vm}'") update_progress[vm] = 100 else: sdlog.error(f"Update failed for template: '{vm}'") def _check_updates_dom0(): """ We need to reboot the system after every dom0 update. The update script does not tell us through its exit code whether updates were applied, and parsing command output can be brittle. For this reason, we check for available updates first. The result of this check is cached, so it does not incur a significant performance penalty. """ try: subprocess.check_call(["sudo", "qubes-dom0-update", "--check-only"]) except subprocess.CalledProcessError as e: sdlog.error("dom0 updates required, or cannot check for updates") sdlog.error(str(e)) return UpdateStatus.UPDATES_REQUIRED sdlog.info("No updates available for dom0") return UpdateStatus.UPDATES_OK def _apply_updates_dom0(): """ Apply updates to dom0. Any update to dom0 will require a reboot after the upgrade. """ sdlog.info("Updating dom0") try: subprocess.check_call(["sudo", "qubes-dom0-update", "-y"]) except subprocess.CalledProcessError as e: sdlog.error("An error has occurred updating dom0. Please contact your administrator.") sdlog.error(str(e)) return UpdateStatus.UPDATES_FAILED sdlog.info("dom0 updates have been applied and a reboot is required.") return UpdateStatus.REBOOT_REQUIRED def _write_last_updated_flags_to_disk(): """ Writes the time of last successful upgrade to dom0 and sd-app """ current_date = str(datetime.now().strftime(DATE_FORMAT)) flag_file_sd_app_last_updated = FLAG_FILE_LAST_UPDATED_SD_APP flag_file_dom0_last_updated = get_dom0_path(FLAG_FILE_LAST_UPDATED_DOM0) try: sdlog.info(f"Setting last updated to {current_date} in sd-app") subprocess.check_call( [ "qvm-run", "sd-app", f"echo '{current_date}' > {flag_file_sd_app_last_updated}", ] ) except subprocess.CalledProcessError as e: sdlog.error("Error writing last updated flag to sd-app") sdlog.error(str(e)) try: sdlog.info(f"Setting last updated to {current_date} in dom0") if not os.path.exists(os.path.dirname(flag_file_dom0_last_updated)): os.makedirs(os.path.dirname(flag_file_dom0_last_updated)) with open(flag_file_dom0_last_updated, "w") as f: f.write(current_date) except Exception as e: sdlog.error("Error writing last updated flag to dom0") sdlog.error(str(e)) def _write_updates_status_flag_to_disk(status): """ Writes the latest SecureDrop Workstation update status to disk, on both dom0 and sd-app for futher processing in the future. """ flag_file_path_sd_app = FLAG_FILE_STATUS_SD_APP flag_file_path_dom0 = get_dom0_path(FLAG_FILE_STATUS_DOM0) try: sdlog.info(f"Setting update flag to {status.value} in sd-app") subprocess.check_call( ["qvm-run", "sd-app", f"echo '{status.value}' > {flag_file_path_sd_app}"] ) except subprocess.CalledProcessError as e: sdlog.error("Error writing update status flag to sd-app") sdlog.error(str(e)) try: sdlog.info(f"Setting update flag to {status.value} in dom0") if not os.path.exists(os.path.dirname(flag_file_path_dom0)): os.makedirs(os.path.dirname(flag_file_path_dom0)) current_date = str(datetime.now().strftime(DATE_FORMAT)) with open(flag_file_path_dom0, "w") as f: flag_contents = {"last_status_update": current_date, "status": status.value} json.dump(flag_contents, f) except Exception as e: sdlog.error("Error writing update status flag to dom0") sdlog.error(str(e)) def last_required_reboot_performed(): """ Checks if the dom0 flag file indicates that a reboot is required, and if so, will check current uptime with the data at which the reboot was requested. This will be used by the _write_updates_status_flag_to_disk function to preserve the status UPDATES_REQUIRED instead of updating. """ flag_contents = read_dom0_update_flag_from_disk(with_timestamp=True) # No flag exists on disk (yet) if flag_contents is None: return True if int(flag_contents["status"]) == int(UpdateStatus.REBOOT_REQUIRED.value): reboot_time = datetime.strptime(flag_contents["last_status_update"], DATE_FORMAT) boot_time = datetime.now() - _get_uptime() # The session was started *before* the reboot was requested by # the updater, system was not rebooted after previous run if boot_time < reboot_time: return False # system was rebooted after flag was written to disk return True # previous run did not require reboot return True def _get_uptime(): """ Returns timedelta containing system (dom0) uptime. """ uptime = None with open("/proc/uptime") as f: uptime = f.read().split(" ")[0].strip() uptime = int(float(uptime)) uptime_hours = uptime // 3600 uptime_minutes = (uptime % 3600) // 60 uptime_seconds = uptime % 60 return timedelta(hours=uptime_hours, minutes=uptime_minutes, seconds=uptime_seconds) def read_dom0_update_flag_from_disk(with_timestamp=False): """ Read the last updated SecureDrop Workstation update status from disk in dom0, and returns the corresponding UpdateStatus. If ivoked the parameter `with_timestamp=True`, this function will return the full json. """ flag_file_path_dom0 = get_dom0_path(FLAG_FILE_STATUS_DOM0) try: with open(flag_file_path_dom0) as f: contents = json.load(f) for status in UpdateStatus: if int(contents["status"]) == int(status.value): if with_timestamp: return contents return status except Exception: sdlog.info("Cannot read dom0 status flag, assuming first run") return None def overall_update_status(results): """ Helper method that returns the worst-case status For now, simple logic for reboot required: If dom0 or fedora updates, a reboot will be required. """ updates_failed = False updates_required = False reboot_required = False # Ensure the user has rebooted after the previous installer run required a reboot if not last_required_reboot_performed(): return UpdateStatus.REBOOT_REQUIRED for result in results.values(): if result == UpdateStatus.UPDATES_FAILED: updates_failed = True if result == UpdateStatus.UPDATES_IN_PROGRESS: updates_failed = True elif result == UpdateStatus.REBOOT_REQUIRED: reboot_required = True elif result == UpdateStatus.UPDATES_REQUIRED: updates_required = True if updates_failed: return UpdateStatus.UPDATES_FAILED if reboot_required: return UpdateStatus.REBOOT_REQUIRED if updates_required: return UpdateStatus.UPDATES_REQUIRED return UpdateStatus.UPDATES_OK def apply_dom0_state(): """ Applies the dom0 state to ensure dom0 and AppVMs are properly Configured. This will *not* enforce configuration inside the AppVMs. Here, we call qubectl directly (instead of through sdw-admin) to ensure it is environment-specific. """ sdlog.info("Applying dom0 state") cmd = ["sudo", "qubesctl", "--show-output", "state.highstate"] cmd_for_log = " ".join(cmd) try: output = subprocess.check_output(cmd) sdlog.info("Dom0 state applied") clean_output = Util.strip_ansi_colors(output.decode("utf-8").strip()) detail_log.info(f"Output from command: {cmd_for_log}\n{clean_output}") return UpdateStatus.UPDATES_OK except subprocess.CalledProcessError as e: sdlog.error(f"Failed to apply dom0 state. See {DETAIL_LOG_FILE} for details.") sdlog.error(str(e)) clean_output = Util.strip_ansi_colors(e.output.decode("utf-8").strip()) detail_log.error(f"Output from failed command: {cmd_for_log}\n{clean_output}") return UpdateStatus.UPDATES_FAILED def should_launch_updater(interval): status = read_dom0_update_flag_from_disk(with_timestamp=True) if _valid_status(status): if _interval_expired(interval, status): sdlog.info("Update interval expired: launching updater.") return True if status["status"] == UpdateStatus.UPDATES_OK.value: sdlog.info("Updates OK and interval not expired, launching client.") return False if status["status"] == UpdateStatus.REBOOT_REQUIRED.value: if last_required_reboot_performed(): sdlog.info("Required reboot performed, updating status and launching client.") _write_updates_status_flag_to_disk(UpdateStatus.UPDATES_OK) return False sdlog.info("Required reboot pending, launching updater") return True if status["status"] == UpdateStatus.UPDATES_REQUIRED.value: sdlog.info("Updates are required, launching updater.") return True if status["status"] == UpdateStatus.UPDATES_FAILED.value: sdlog.info("Preceding update failed, launching updater.") return True sdlog.info("Update status is unknown, launching updater.") return True sdlog.info("Update status not available, launching updater.") return True def _valid_status(status): """ status should contain 2 items, the update flag and a timestamp. """ if isinstance(status, dict) and len(status) == 2: return True return False def _interval_expired(interval, status): """ Check if specified update interval has expired. """ try: update_time = datetime.strptime(status["last_status_update"], DATE_FORMAT) except ValueError: # Broken timestamp? run the updater. return True if (datetime.now() - update_time) < timedelta(seconds=interval): return False return True class UpdateStatus(Enum): """ Standardizes return codes for update/upgrade methods """ UPDATES_OK = "0" UPDATES_REQUIRED = "1" REBOOT_REQUIRED = "2" UPDATES_FAILED = "3" UPDATES_IN_PROGRESS = "4" @classmethod def from_qubes_updater_name(cls, name): """ Maps qubes updater's terminology to SDW Updater one. Upstream code found in: https://github.com/QubesOS/qubes-desktop-linux-manager/blob/4afc35/qui/updater/utils.py#L199C25-L204C27 """ names = { "success": cls.UPDATES_OK, "error": cls.UPDATES_FAILED, "no_updates": cls.UPDATES_OK, "cancelled": cls.UPDATES_FAILED, } try: return names[name] except KeyError: sdlog.error("Qubes updater provided an invalid update status.") return cls.UPDATES_FAILED