#!/usr/bin/python3 # SPDX-License-Identifier: AGPL-3.0-or-later """ Configures or runs unattended-upgrades """ import argparse import os import pathlib import re import subprocess import sys from plinth.action_utils import run_apt_command from plinth.modules.apache.components import check_url from plinth.modules.upgrades import (get_current_release, is_backports_current, SOURCES_LIST) AUTO_CONF_FILE = '/etc/apt/apt.conf.d/20auto-upgrades' LOG_FILE = '/var/log/unattended-upgrades/unattended-upgrades.log' DPKG_LOG_FILE = '/var/log/unattended-upgrades/unattended-upgrades-dpkg.log' BACKPORTS_RELEASE_FILE_URL = \ 'https://deb.debian.org/debian/dists/{}-backports/Release' APT_PREFERENCES_FREEDOMBOX = '''Explanation: This file is managed by FreedomBox, do not edit. Explanation: Allow carefully selected updates to 'freedombox' from backports. Package: freedombox Pin: release a={}-backports Pin-Priority: 500 ''' # Whenever these preferences needs to change, increment the version number # upgrades app. This ensures that setup is run again and the new contents are # overwritten on the old file. APT_PREFERENCES_APPS = '''Explanation: This file is managed by FreedomBox, do not edit. Explanation: matrix-synapse 0.99.5 introduces room version 4. Older version Explanation: 0.99.2 in buster won't be able join newly created rooms. Package: matrix-synapse Pin: release a=buster-backports Pin-Priority: 500 Explanation: matrix-synapse >= 1.2 requires python3-service-identity >= 18.1 Package: python3-service-identity Pin: release a=buster-backports Pin-Priority: 500 Explanation: matrix-synapse >= 1.5 requires python3-typing-extensions >= 3.7.4 Package: python3-typing-extensions Pin: release a=buster-backports Pin-Priority: 500 Explanation: matrix-synapse >= 1.11 requires python3-signedjson >= 1.1.0 Package: python3-signedjson Pin: release a=buster-backports Pin-Priority: 500 Explanation: matrix-synapse >= 1.12 requires python3-twisted >= 18.9.0-8~ Package: python3-twisted Pin: release a=buster-backports Pin-Priority: 500 Explanation: python3-twisted requires matching version of python3-twisted-bin Package: python3-twisted-bin Pin: release a=buster-backports Pin-Priority: 500 ''' def parse_arguments(): """Return parsed command line arguments as dictionary""" parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') subparsers.add_parser('run', help='Upgrade packages on the system') subparsers.add_parser('check-auto', help='Check if automatic upgrades are enabled') subparsers.add_parser('enable-auto', help='Enable automatic upgrades') subparsers.add_parser('disable-auto', help='Disable automatic upgrades.') subparsers.add_parser('get-log', help='Print the automatic upgrades log') subparsers.add_parser('setup', help='Setup apt preferences') setup_repositories = subparsers.add_parser( 'setup-repositories', help='Setup software repositories for FreedomBox') setup_repositories.add_argument('--develop', required=False, default=False, action='store_true', help='Development mode') subparsers.required = True return parser.parse_args() def subcommand_run(_): """Run unattended-upgrades""" subprocess.run(['dpkg', '--configure', '-a']) run_apt_command(['--fix-broken', 'install']) try: subprocess.Popen(['systemctl', 'start', 'freedombox-manual-upgrade'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, start_new_session=True) except FileNotFoundError: print('Error: systemctl is not available.', file=sys.stderr) sys.exit(2) except Exception as error: print('Error: {0}'.format(error), file=sys.stderr) sys.exit(3) def subcommand_check_auto(_): """Check if automatic upgrades are enabled""" arguments = [ 'apt-config', 'shell', 'UpdateInterval', 'APT::Periodic::Update-Package-Lists' ] try: output = subprocess.check_output(arguments).decode() except subprocess.CalledProcessError as error: print('Error: {0}'.format(error), file=sys.stderr) sys.exit(1) update_interval = 0 match = re.match(r"UpdateInterval='(.*)'", output) if match: update_interval = int(match.group(1)) print(bool(update_interval)) def subcommand_enable_auto(_): """Enable automatic upgrades""" with open(AUTO_CONF_FILE, 'w') as conffile: conffile.write('APT::Periodic::Update-Package-Lists "1";\n') conffile.write('APT::Periodic::Unattended-Upgrade "1";\n') def subcommand_disable_auto(_): """Disable automatic upgrades""" with open(AUTO_CONF_FILE, 'w') as conffile: conffile.write('APT::Periodic::Update-Package-Lists "0";\n') conffile.write('APT::Periodic::Unattended-Upgrade "0";\n') def subcommand_get_log(_): """Print the automatic upgrades log.""" try: print('==> ' + os.path.basename(LOG_FILE)) with open(LOG_FILE, 'r') as file_handle: print(file_handle.read()) except IOError: pass try: print('==> ' + os.path.basename(DPKG_LOG_FILE)) with open(DPKG_LOG_FILE, 'r') as file_handle: print(file_handle.read()) except IOError: pass def _get_protocol(): """Return the protocol to use for newly added repository sources.""" try: from plinth.modules.tor import utils if utils.is_apt_transport_tor_enabled(): return 'tor+http' except Exception: pass return 'http' def _is_release_file_available(protocol, dist): """Return whether the release for backports is available.""" wrapper = None if protocol == 'tor+http': wrapper = 'torsocks' result = check_url(BACKPORTS_RELEASE_FILE_URL.format(dist), wrapper=wrapper) return result == 'passed' def _add_backports_sources(sources_list, protocol, dist): """Add backports sources to freedombox repositories list.""" sources = '''# This file is managed by FreedomBox, do not edit. # Allow carefully selected updates to 'freedombox' from backports. deb {protocol}://deb.debian.org/debian {dist}-backports main deb-src {protocol}://deb.debian.org/debian {dist}-backports main ''' sources = sources.format(protocol=protocol, dist=dist) with open(sources_list, 'w') as file_handle: file_handle.write(sources) def _check_and_backports_sources(develop=False): """Add backports sources after checking if it is available.""" old_sources_list = '/etc/apt/sources.list.d/freedombox.list' if os.path.exists(old_sources_list): os.remove(old_sources_list) if is_backports_current(): print('Repositories list up-to-date. Skipping update.') return try: with open('/etc/dpkg/origins/default', 'r') as default_origin: matches = [ re.match(r'Vendor:\s+Debian', line, flags=re.IGNORECASE) for line in default_origin.readlines() ] except FileNotFoundError: print('Could not open /etc/dpkg/origins/default') return if not any(matches): print('System is running a derivative of Debian. Skip enabling ' 'backports.') return release, dist = get_current_release() if release == 'unstable' or (release == 'testing' and not develop): print(f'System release is {release}. Skip enabling backports.') return protocol = _get_protocol() if protocol == 'tor+http': print('Package download over Tor is enabled.') if not _is_release_file_available(protocol, dist): print(f'Release file for {dist}-backports is not available yet.') return print(f'{dist}-backports is now available. Adding to sources.') _add_backports_sources(SOURCES_LIST, protocol, dist) # In case of dist upgrade, rewrite the preferences file. _add_apt_preferences() def _add_apt_preferences(): """Setup APT preferences to upgrade selected packages from backports.""" base_path = pathlib.Path('/etc/apt/preferences.d') for file_name in ['50freedombox.pref', '50freedombox2.pref']: full_path = base_path / file_name if full_path.exists(): full_path.unlink() # Don't try to remove 50freedombox3.pref as this file is shipped with the # Debian package and is removed using maintainer scripts. _, dist = get_current_release() if dist == 'sid': print(f'System distribution is {dist}. Skip setting apt preferences ' 'for backports.') else: print(f'Setting apt preferences for {dist}-backports.') with open(base_path / '50freedombox4.pref', 'w') as file_handle: file_handle.write(APT_PREFERENCES_FREEDOMBOX.format(dist)) with open(base_path / '51freedombox-apps.pref', 'w') as file_handle: file_handle.write(APT_PREFERENCES_APPS) def subcommand_setup(_): """Setup apt preferences.""" _add_apt_preferences() def subcommand_setup_repositories(arguments): """Setup software repositories needed for FreedomBox. Repositories list for now only contains the backports. If the file exists, assume that it contains backports. """ _check_and_backports_sources(arguments.develop) def main(): """Parse arguments and perform all duties""" arguments = parse_arguments() subcommand = arguments.subcommand.replace('-', '_') subcommand_method = globals()['subcommand_' + subcommand] subcommand_method(arguments) if __name__ == '__main__': main()