diff --git a/VERSION b/VERSION index b1e80bb24..845639eef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.4 diff --git a/launcher/sdw-launcher.py b/launcher/sdw-launcher.py index 5d67ee3fe..2b93d5c69 100644 --- a/launcher/sdw-launcher.py +++ b/launcher/sdw-launcher.py @@ -3,12 +3,33 @@ from sdw_updater_gui.UpdaterApp import UpdaterApp from sdw_util import Util from sdw_updater_gui import Updater - +from sdw_updater_gui.UpdaterApp import launch_securedrop_client +from sdw_updater_gui.Updater import should_launch_updater import logging import sys +import argparse + +DEFAULT_INTERVAL = 28800 # 8hr default for update interval + + +def parse_argv(argv): + parser = argparse.ArgumentParser() + parser.add_argument("--skip-delta", type=int) + return parser.parse_args(argv) + + +def launch_updater(): + """ + Start the updater GUI + """ + + app = QtGui.QApplication(sys.argv) + form = UpdaterApp() + form.show() + sys.exit(app.exec_()) -def main(): +def main(argv): sdlog = logging.getLogger(__name__) Util.configure_logging(Updater.LOG_FILE) lock_handle = Util.obtain_lock(Updater.LOCK_FILE) @@ -17,11 +38,24 @@ def main(): # Logged. sys.exit(1) sdlog.info("Starting SecureDrop Launcher") - app = QtGui.QApplication(sys.argv) - form = UpdaterApp() - form.show() - sys.exit(app.exec_()) + + args = parse_argv(argv) + + try: + args.skip_delta + except NameError: + args.skip_delta = DEFAULT_INTERVAL + + if args.skip_delta is None: + args.skip_delta = DEFAULT_INTERVAL + + interval = int(args.skip_delta) + + if should_launch_updater(interval): + launch_updater() + else: + launch_securedrop_client() if __name__ == "__main__": - main() + main(sys.argv[1:]) diff --git a/launcher/sdw_updater_gui/Updater.py b/launcher/sdw_updater_gui/Updater.py index 40f4f9fd6..30917730c 100644 --- a/launcher/sdw_updater_gui/Updater.py +++ b/launcher/sdw_updater_gui/Updater.py @@ -430,6 +430,77 @@ def _safely_start_vm(vm): sdlog.error(str(e)) +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 + else: + if status["status"] == UpdateStatus.UPDATES_OK.value: + sdlog.info("Updates OK and interval not expired, launching client.") + return False + elif 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 + else: + sdlog.info("Required reboot pending, launching updater") + return True + elif status["status"] == UpdateStatus.UPDATES_REQUIRED.value: + sdlog.info( + "Updates are required, launching updater.".format( + str(status["status"]) + ) + ) + return True + elif status["status"] == UpdateStatus.UPDATES_FAILED.value: + sdlog.info( + "Preceding update failed, launching updater.".format( + str(status["status"]) + ) + ) + return True + else: + sdlog.info( + "Update status is unknown, launching updater.".format( + str(status["status"]) + ) + ) + return True + else: + 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 diff --git a/launcher/sdw_updater_gui/UpdaterApp.py b/launcher/sdw_updater_gui/UpdaterApp.py index 07f7a7d34..ec1411bfe 100644 --- a/launcher/sdw_updater_gui/UpdaterApp.py +++ b/launcher/sdw_updater_gui/UpdaterApp.py @@ -11,6 +11,19 @@ logger = logging.getLogger(__name__) +def launch_securedrop_client(): + """ + Helper function to launch the SecureDrop Client + """ + try: + logger.info("Launching SecureDrop client") + subprocess.Popen(["qvm-run", "sd-app", "gtk-launch securedrop-client"]) + except subprocess.CalledProcessError as e: + logger.error("Error while launching SecureDrop client") + logger.error(str(e)) + sys.exit(0) + + class UpdaterApp(QtGui.QMainWindow, Ui_UpdaterDialog): def __init__(self, parent=None): super(UpdaterApp, self).__init__(parent) @@ -19,7 +32,7 @@ def __init__(self, parent=None): self.setupUi(self) self.clientOpenButton.setEnabled(False) self.clientOpenButton.hide() - self.clientOpenButton.clicked.connect(self.launch_securedrop_client) + self.clientOpenButton.clicked.connect(launch_securedrop_client) self.rebootButton.setEnabled(False) self.rebootButton.hide() self.rebootButton.clicked.connect(self.reboot_workstation) @@ -173,19 +186,6 @@ def get_vms_that_need_upgrades(self, results): vms_to_upgrade.append(vm) return vms_to_upgrade - def launch_securedrop_client(self): - """ - Helper method to launch the SecureDrop Client - """ - try: - logger.info("Launching SecureDrop client") - subprocess.Popen(["qvm-run", "sd-app", "gtk-launch securedrop-client"]) - except subprocess.CalledProcessError as e: - self.proposedActionDescription.setText(strings.descri) - logger.error("Error while launching SecureDrop client") - logger.error(str(e)) - sys.exit(0) - def apply_all_updates(self): """ Method used by the applyUpdatesButton that will create and start an @@ -246,10 +246,11 @@ def run(self): results[vm] = result self.progress_signal.emit(progress) - # write the flags to disk + # write the flags to disk after successful updates, including updates + # that require a reboot. run_results = Updater.overall_update_status(results) Updater._write_updates_status_flag_to_disk(run_results) - if run_results == UpdateStatus.UPDATES_OK: + if run_results in {UpdateStatus.UPDATES_OK, UpdateStatus.REBOOT_REQUIRED}: Updater._write_last_updated_flags_to_disk() # populate signal contents message = results # copy all the information from results diff --git a/launcher/tests/test_updater.py b/launcher/tests/test_updater.py index d33324f63..fbb8f8e88 100644 --- a/launcher/tests/test_updater.py +++ b/launcher/tests/test_updater.py @@ -2,8 +2,8 @@ import os import pytest import subprocess -from datetime import datetime from importlib.machinery import SourceFileLoader +from datetime import datetime, timedelta from tempfile import TemporaryDirectory from unittest import mock from unittest.mock import call @@ -191,7 +191,7 @@ def test_check_updates_debian_updates_required( call("Command 'check_call' returned non-zero exit status 1."), ] info_log = [ - call("Checking for updates {}:{}".format("sd-app", "sd-app-buster-template")), + call("Checking for updates {}:{}".format("sd-app", "sd-app-buster-template")) ] mocked_error.assert_has_calls(error_log) mocked_info.assert_has_calls(info_log) @@ -216,7 +216,7 @@ def test_check_debian_updates_failed(mocked_info, mocked_error, mocked_call, cap call("Command 'check_call' returned non-zero exit status 1."), ] info_log = [ - call("Checking for updates {}:{}".format("sd-app", "sd-app-buster-template")), + call("Checking for updates {}:{}".format("sd-app", "sd-app-buster-template")) ] mocked_error.assert_has_calls(error_log) mocked_info.assert_has_calls(info_log) @@ -238,7 +238,7 @@ def test_check_debian_has_updates(mocked_info, mocked_error, mocked_call, capsys call("Command 'check_call' returned non-zero exit status 1."), ] info_log = [ - call("Checking for updates {}:{}".format("sd-log", "sd-log-buster-template")), + call("Checking for updates {}:{}".format("sd-log", "sd-log-buster-template")) ] status = updater._check_updates_debian("sd-log") @@ -289,9 +289,7 @@ def test_check_updates_calls_correct_commands( call(["qvm-shutdown", "--wait", current_templates[vm]]), ] elif vm == "dom0": - subprocess_call_list = [ - call(["sudo", "qubes-dom0-update", "--check-only"]), - ] + subprocess_call_list = [call(["sudo", "qubes-dom0-update", "--check-only"])] else: pytest.fail("Unupported VM: {}".format(vm)) mocked_call.assert_has_calls(subprocess_call_list) @@ -452,10 +450,7 @@ def test_write_updates_status_flag_to_disk_failure_dom0( mocked_info, mocked_error, mocked_call, mocked_expand, mocked_open, status ): - error_calls = [ - call("Error writing update status flag to dom0"), - call("os_error"), - ] + error_calls = [call("Error writing update status flag to dom0"), call("os_error")] updater._write_updates_status_flag_to_disk(status) mocked_error.assert_has_calls(error_calls) @@ -668,9 +663,7 @@ def test_overall_update_status_reboot_not_done_previously( @mock.patch("Updater.sdlog.error") @mock.patch("Updater.sdlog.info") def test_safely_shutdown(mocked_info, mocked_error, mocked_call, vm): - call_list = [ - call(["qvm-shutdown", "--wait", "{}".format(vm)]), - ] + call_list = [call(["qvm-shutdown", "--wait", "{}".format(vm)])] updater._safely_shutdown_vm(vm) mocked_call.assert_has_calls(call_list) @@ -682,9 +675,7 @@ def test_safely_shutdown(mocked_info, mocked_error, mocked_call, vm): @mock.patch("Updater.sdlog.error") @mock.patch("Updater.sdlog.info") def test_safely_start(mocked_info, mocked_error, mocked_call, vm): - call_list = [ - call(["qvm-start", "--skip-if-running", "{}".format(vm)]), - ] + call_list = [call(["qvm-start", "--skip-if-running", "{}".format(vm)])] updater._safely_start_vm(vm) mocked_call.assert_has_calls(call_list) @@ -730,12 +721,7 @@ def test_safely_shutdown_fails(mocked_info, mocked_error, mocked_call, vm): def test_shutdown_and_start_vms( mocked_info, mocked_error, mocked_shutdown, mocked_start ): - call_list = [ - call("sd-proxy"), - call("sd-whonix"), - call("sd-app"), - call("sd-gpg"), - ] + call_list = [call("sd-proxy"), call("sd-whonix"), call("sd-app"), call("sd-gpg")] updater._shutdown_and_start_vms() mocked_shutdown.assert_has_calls(call_list) mocked_start.assert_has_calls(call_list) @@ -784,9 +770,7 @@ def test_read_dom0_update_flag_from_disk_fails( except Exception: pytest.fail("Error writing file") - info_calls = [ - call("Cannot read dom0 status flag, assuming first run"), - ] + info_calls = [call("Cannot read dom0 status flag, assuming first run")] assert updater.read_dom0_update_flag_from_disk() is None assert not mocked_error.called @@ -825,6 +809,15 @@ def test_last_required_reboot_performed_failed(mocked_info, mocked_error, mocked assert not mocked_error.called +@mock.patch("Updater.read_dom0_update_flag_from_disk", return_value=None) +@mock.patch("Updater.sdlog.error") +@mock.patch("Updater.sdlog.info") +def test_last_required_reboot_performed_no_file(mocked_info, mocked_error, mocked_read): + result = updater.last_required_reboot_performed() + assert result is True + assert not mocked_error.called + + @mock.patch( "Updater.read_dom0_update_flag_from_disk", return_value={ @@ -840,3 +833,111 @@ def test_last_required_reboot_performed_not_required( result = updater.last_required_reboot_performed() assert result is True assert not mocked_error.called + + +@pytest.mark.parametrize( + "status, rebooted, expect_status_change, expect_updater", + [ + (UpdateStatus.UPDATES_OK, True, False, True), + (UpdateStatus.UPDATES_REQUIRED, True, False, True), + (UpdateStatus.REBOOT_REQUIRED, True, False, True), + (UpdateStatus.UPDATES_FAILED, True, False, True), + (UpdateStatus.UPDATES_OK, False, False, True), + (UpdateStatus.UPDATES_REQUIRED, False, False, True), + (UpdateStatus.REBOOT_REQUIRED, False, False, True), + (UpdateStatus.UPDATES_FAILED, False, False, True), + ], +) +@mock.patch("Updater._write_updates_status_flag_to_disk") +def test_should_run_updater_status_interval_expired( + mocked_write, status, rebooted, expect_status_change, expect_updater +): + TEST_INTERVAL = 3600 + # the updater should always run when checking interval has expired, + # regardless of update or reboot status + with mock.patch("Updater.last_required_reboot_performed") as mocked_last: + mocked_last.return_value = rebooted + with mock.patch("Updater.read_dom0_update_flag_from_disk") as mocked_read: + mocked_read.return_value = { + "last_status_update": str( + (datetime.now() - timedelta(seconds=(TEST_INTERVAL + 10))).strftime( + updater.DATE_FORMAT + ) + ), + "status": status.value, + } + # assuming that the tests won't take an hour to run! + assert expect_updater == updater.should_launch_updater(TEST_INTERVAL) + assert expect_status_change == mocked_write.called + + +@pytest.mark.parametrize( + "status, rebooted, expect_status_change, expect_updater", + [ + (UpdateStatus.UPDATES_OK, True, False, False), + (UpdateStatus.UPDATES_REQUIRED, True, False, True), + (UpdateStatus.REBOOT_REQUIRED, True, True, False), + (UpdateStatus.UPDATES_FAILED, True, False, True), + (UpdateStatus.UPDATES_OK, False, False, False), + (UpdateStatus.UPDATES_REQUIRED, False, False, True), + (UpdateStatus.REBOOT_REQUIRED, False, False, True), + (UpdateStatus.UPDATES_FAILED, False, False, True), + ], +) +@mock.patch("Updater._write_updates_status_flag_to_disk") +def test_should_run_updater_status_interval_not_expired( + mocked_write, status, rebooted, expect_status_change, expect_updater +): + TEST_INTERVAL = 3600 + # Even if the interval hasn't expired, the updater should only be skipped when: + # - the updater status is UPDATESr_OK, or + # - the updater status is REBOOT_REQUIRED and the reboot has been performed. + with mock.patch("Updater.last_required_reboot_performed") as mocked_last: + mocked_last.return_value = rebooted + with mock.patch("Updater.read_dom0_update_flag_from_disk") as mocked_read: + mocked_read.return_value = { + "last_status_update": str(datetime.now().strftime(updater.DATE_FORMAT)), + "status": status.value, + } + # assuming that the tests won't take an hour to run! + assert expect_updater == updater.should_launch_updater(TEST_INTERVAL) + assert expect_status_change == mocked_write.called + + +@mock.patch("Updater._write_updates_status_flag_to_disk") +def test_should_run_updater_invalid_status(mocked_write): + TEST_INTERVAL = 3600 + with mock.patch("Updater.last_required_reboot_performed") as mocked_last: + mocked_last.return_value = True + with mock.patch("Updater.read_dom0_update_flag_from_disk") as mocked_read: + mocked_read.return_value = {} + # assuming that the tests won't take an hour to run! + assert updater.should_launch_updater(TEST_INTERVAL) is True + + +@mock.patch("Updater._write_updates_status_flag_to_disk") +def test_should_run_updater_invalid_timestamp(mocked_write): + TEST_INTERVAL = 3600 + with mock.patch("Updater.last_required_reboot_performed") as mocked_last: + mocked_last.return_value = True + with mock.patch("Updater.read_dom0_update_flag_from_disk") as mocked_read: + mocked_read.return_value = { + "last_status_update": "time to die", + "status": UpdateStatus.UPDATES_OK.value, + } + # assuming that the tests won't take an hour to run! + assert updater.should_launch_updater(TEST_INTERVAL) is True + + +@mock.patch("Updater._write_updates_status_flag_to_disk") +def test_should_run_updater_invalid_status_value(mocked_write): + TEST_INTERVAL = 3600 + with mock.patch("Updater.last_required_reboot_performed") as mocked_last: + mocked_last.return_value = True + with mock.patch("Updater.read_dom0_update_flag_from_disk") as mocked_read: + mocked_read.return_value = { + "last_status_update": str(datetime.now().strftime(updater.DATE_FORMAT)), + "status": "5", + } + # assuming that the tests won't take an hour to run! + assert updater.should_launch_updater(TEST_INTERVAL) is True diff --git a/rpm-build/SPECS/securedrop-workstation-dom0-config.spec b/rpm-build/SPECS/securedrop-workstation-dom0-config.spec index fc28a4589..45dba7659 100644 --- a/rpm-build/SPECS/securedrop-workstation-dom0-config.spec +++ b/rpm-build/SPECS/securedrop-workstation-dom0-config.spec @@ -1,12 +1,12 @@ Name: securedrop-workstation-dom0-config -Version: 0.1.3 +Version: 0.1.4 Release: 1%{?dist} Summary: SecureDrop Workstation Group: Library License: GPLv3+ URL: https://github.com/freedomofpress/securedrop-workstation -Source0: securedrop-workstation-dom0-config-0.1.3.tar.gz +Source0: securedrop-workstation-dom0-config-0.1.4.tar.gz BuildArch: noarch BuildRequires: python3-setuptools @@ -94,6 +94,9 @@ find /srv/salt -maxdepth 1 -type f -iname '*.top' \ | xargs qubesctl top.enable > /dev/null %changelog +* Fri Feb 14 2020 SecureDrop Team - 0.1.4 +- Modifies updater to allow for a configurable interval between checks + * Tue Feb 11 2020 SecureDrop Team - 0.1.3 - Adds sdw-notify script - Sets executable bits within package specification