From b8577115bb6482d437c308772a3fe7d21d1ba60f Mon Sep 17 00:00:00 2001 From: Guido Günther Date: Thu, 27 Mar 2014 06:48:49 +0100 Subject: Add README --- README | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 README diff --git a/README b/README new file mode 100644 index 0000000..2a191f7 --- /dev/null +++ b/README @@ -0,0 +1,3 @@ +See + +https://honk.sigxcpu.org/piki/projects/whatmaps/ -- cgit v1.2.3 From 72bf838de4f518f01a0d3ac0fefc53bfff332ec8 Mon Sep 17 00:00:00 2001 From: Guido Günther Date: Tue, 8 Apr 2014 21:39:00 +0200 Subject: Update README a bit --- README | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README b/README index 2a191f7..c35fe66 100644 --- a/README +++ b/README @@ -1,3 +1,37 @@ +After a security update of a library one needs to restart all programs that +make use of this library in order for the security update to become effective. +whatmaps takes a package name and looks (on Linux systems) in /proc/pid/maps +for processes that map shared objects contained in a certain package. It then +looks for the corresponding processes to find the services that need to be +restarted. + +It currently supports Debian and Fedora but might also work well on other +Debian or RPM based distributions. + +Usage +===== +Pass the names of the packages that had shared objects updated: + + whatmaps + +The example output looks like: + + # whatmaps libssl0.9.8 + WARNING: No service script found in 'wpasupplicant' for '['/sbin/wpa_supplicant']' - restart manually + Services that possibly need to be restarted: + postfix + libvirt-bin + ntpd + +Apt Integration +=============== + +To enable automatic restarts during security updates on Debian based systems set: + + Whatmaps::Enable-Restart "1"; + +in /etc/apt/apt.conf.d/20services. + See https://honk.sigxcpu.org/piki/projects/whatmaps/ -- cgit v1.2.3 From a785b391030e9a8936ae61c2de890082311a8c42 Mon Sep 17 00:00:00 2001 From: Guido Günther Date: Fri, 18 Apr 2014 12:39:25 +0200 Subject: Add .gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9b90d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.coverage +*.pyc +*~ +build/ +cover/ +whatmaps.egg-info/ -- cgit v1.2.3 From 0e8224cf4b6d2a5ef6e8c7165dc6143db3cbc482 Mon Sep 17 00:00:00 2001 From: Guido Günther Date: Fri, 18 Apr 2014 11:52:52 +0200 Subject: Use entrypoint --- setup.py | 5 +- whatmaps | 608 -------------------------------------------------- whatmaps/__init__.py | 0 whatmaps/command.py | 611 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 615 insertions(+), 609 deletions(-) delete mode 100755 whatmaps create mode 100644 whatmaps/__init__.py create mode 100755 whatmaps/command.py diff --git a/setup.py b/setup.py index 1da9283..46b0d81 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,10 @@ setup(name = "whatmaps", author = 'Guido Günther', author_email = 'agx@sigxcpu.org', data_files = data_files, - scripts = [ 'whatmaps' ], + packages = ['whatmaps'], + entry_points = { + 'console_scripts': [ 'whatmaps = whatmaps.command:run' ], + }, ) # vim:et:ts=4:sw=4:et:sts=4:ai:set list listchars=tab\:»·,trail\:·: diff --git a/whatmaps b/whatmaps deleted file mode 100755 index cca0701..0000000 --- a/whatmaps +++ /dev/null @@ -1,608 +0,0 @@ -#!/usr/bin/python -u -# vim: set fileencoding=utf-8 : -# -# (C) 2010,2014 Guido Günther -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# - -import glob -import os -import logging -import re -import string -import subprocess -import sys -import errno -from optparse import OptionParser -try: - import apt_pkg -except ImportError: - apt_pkg = None -try: - import lsb_release -except ImportError: - lsb_release = None - - -class PkgError(Exception): - pass - - -class Process(object): - """A process - Linux only so far, needs /proc mounted""" - deleted_re = re.compile(r"(?P.*) \(deleted\)$") - - def __init__(self, pid): - self.pid = pid - self.mapped = [] - try: - self.exe = os.readlink('/proc/%d/exe' % self.pid) - m = self.deleted_re.match(self.exe) - if m: - self.exe = m.group('exe') - logging.debug("Using deleted exe %s", self.exe) - if not os.path.exists(self.exe): - logging.debug("%s doesn't exist", self.exe) - self.cmdline = open('/proc/%d/cmdline' % self.pid).read() - except OSError: - self.exe = None - self.cmdline = None - - def _read_maps(self): - """Read the SOs from /proc//maps""" - try: - f = file('/proc/%d/maps' % self.pid) - except IOError as e: - # ignore killed process - if e.errno != errno.ENOENT: - raise - return - for line in f: - try: - so = line.split()[5].strip() - self.mapped.append(so) - except IndexError: - pass - - def maps(self, path): - """Check if the process maps the object at path""" - if not self.mapped: - self._read_maps() - - if path in self.mapped: - return True - else: - return False - - def __repr__(self): - return "" % self.pid - - -class Distro(object): - """ - A distribution - @cvar id: distro id as returned by lsb-release - """ - id = None - service_blacklist = set() - _pkg_services = {} - _pkg_blacklist = {} - - @classmethod - def pkg(klass, name): - """Return package object named name""" - raise NotImplementedError - - @classmethod - def pkg_by_file(klass, path): - """Return package object that contains path""" - raise NotImplementedError - - @classmethod - def restart_service_cmd(klass, service): - """Command to restart service""" - raise NotImplementedError - - @classmethod - def restart_service(klass, service): - """Restart a service""" - subprocess.call(klass.restart_service_cmd(service)) - - @classmethod - def pkg_services(klass, pkg): - """ - List of services that package pkg needs restarted that aren't part - of pkg itself - """ - try: - return klass._pkg_services[pkg.name] - except KeyError: - return [] - - @classmethod - def pkg_service_blacklist(klass, pkg): - """ - List of services in pkg that we don't want to be restarted even when - a binary from this package maps a shared lib that changed. - """ - try: - return klass._pkg_service_blacklist[pkg.name] - except KeyError: - return [] - - @classmethod - def has_apt(klass): - """Does the distribution use apt""" - return False - - -class Pkg(object): - """ - A package in a distribution - @var services: list of services provided by package - @var shared_objects: list of shared objects shipped in this package - @cvar type: package type (e.g. RPM or Debian) - @cvar _so_regex: regex that matches shared objects in the list returned by - _get_contents - @cvar _list_contents: command to list contents of a package, will be passed - to subprocess. "$pkg_name" will be replaced by the package - name. - """ - - type = None - services = None - shared_objects = None - _so_regex = re.compile(r'(?P/.*\.so(\.[^/]*)$)') - _list_contents = None - - def __init__(self, name): - self.name = name - self._services = None - self._shared_objects = None - self._contents = None - - def __repr__(self): - return "<%s Pkg object name:'%s'>" % (self.type, self.name) - - def _get_contents(self): - """List of files in the package""" - if self._contents: - return self._contents - else: - cmd = [ string.Template(arg).substitute(arg, pkg_name=self.name) - for arg in self._list_contents ] - list_contents = subprocess.Popen(cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - output = list_contents.communicate()[0] - if list_contents.returncode: - raise PkgError - self.contents = output.split('\n') - return self.contents - - -class DebianDistro(Distro): - "Debian (dpkg) based distribution""" - id = 'Debian' - - _pkg_services = { 'apache2-mpm-worker': [ 'apache2' ], - 'apache2-mpm-prefork': [ 'apache2' ], - 'apache2.2-bin': [ 'apache2' ], - 'dovecot-imapd': [ 'dovecot' ], - 'dovecot-pop3d': [ 'dovecot' ], - 'exim4-daemon-light': [ 'exim4' ], - 'exim4-daemon-heavy': [ 'exim4' ], - 'qemu-system-x86_64': [ 'libvirt-guests' ], - } - - # Per package blacklist - _pkg_service_blacklist = { 'libvirt-bin': [ 'libvirt-guests' ] } - - # Per distro blacklist - service_blacklist = set(['kvm', 'qemu-kvm', 'qemu-system-x86']) - - @classmethod - def pkg(klass, name): - return DebianPkg(name) - - @classmethod - def pkg_by_file(klass, path): - find_file = subprocess.Popen(['dpkg-query', '-S', path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - output = find_file.communicate()[0] - if find_file.returncode: - return None - pkg = output.split(':')[0] - return DebianPkg(pkg) - - @classmethod - def restart_service_cmd(klass, name): - return ['invoke-rc.d', name, 'restart'] - - @classmethod - def has_apt(klass): - return True - - @staticmethod - def read_apt_pipeline(): - whatmaps_enabled = False - - version = sys.stdin.readline().rstrip() - if version != "VERSION 2": - logging.error("Wrong or missing VERSION from apt pipeline\n" - "(is Dpkg::Tools::Options::/usr/bin/whatmaps::Version set to 2?)") - raise PkgError - - while 1: - aptconfig = sys.stdin.readline() - if not aptconfig or aptconfig == '\n': - break - if aptconfig.startswith('Whatmaps::Enable-Restart=') and \ - aptconfig.strip().split('=', 1)[1].lower() in ["true", "1"]: - logging.debug("Service restarts enabled") - whatmaps_enabled = True - - if not whatmaps_enabled: - return None - - pkgs = {} - for line in sys.stdin.readlines(): - if not line: - break - (pkgname, oldversion, compare, newversion, filename) = line.split() - - if filename == '**CONFIGURE**': - if oldversion != '-': # Updates only - pkgs[pkgname] = DebianPkg(pkgname) - pkgs[pkgname].version = newversion - return pkgs - - - @classmethod - def _security_update_origins(klass): - "Determine security update origins from apt configuration" - codename = lsb_release.get_distro_information()['CODENAME'] - def _subst(line): - mapping = {'distro_codename' : codename, - 'distro_id' : klass.id, } - return string.Template(line).substitute(mapping) - - origins = [] - for s in apt_pkg.config.value_list('Whatmaps::Security-Update-Origins'): - (distro_id, distro_codename) = s.split() - origins.append((_subst(distro_id), - _subst(distro_codename))) - logging.debug("Security Update Origins: %s", origins) - return origins - - - @classmethod - def filter_security_updates(klass, pkgs): - """Filter on security updates""" - - apt_pkg.init() - acquire = apt_pkg.Acquire() - cache = apt_pkg.Cache() - - security_update_origins = klass._security_update_origins() - security_updates = {} - - for pkg in pkgs.values(): - cache_pkg = cache[pkg.name] - for cache_version in cache_pkg.version_list: - if pkg.version == cache_version.ver_str: - for pfile, _ in cache_version.file_list: - for origin in security_update_origins: - if pfile.origin == origin[0] and \ - pfile.archive == origin[1]: - security_updates[pkg] = pkg - break - return security_updates - - -class DebianPkg(Pkg): - type = 'Debian' - _init_script_re = re.compile('/etc/init.d/[\w\-\.]') - _list_contents = ['dpkg-query', '-L', '${pkg_name}' ] - - def __init__(self, name): - Pkg.__init__(self, name) - - @property - def shared_objects(self): - if self._shared_objects != None: - return self._shared_objects - - self._shared_objects = [] - contents = self._get_contents() - - for line in contents: - m = self._so_regex.match(line) - if m: - self._shared_objects.append(m.group('so')) - return self._shared_objects - - @property - def services(self): - if self._services != None: - return self._services - - self._services = [] - contents = self._get_contents() - # Only supports sysvinit so far: - for line in contents: - if self._init_script_re.match(line): - self._services.append(os.path.basename(line.strip())) - return self._services - - -class RedHatDistro(Distro): - "RPM based distribution""" - _pkg_re = re.compile(r'(?P[\w\-\+]+)-(?P[\w\.]+)' - '-(?P[\w\.]+)\.(?P.+)') - - @classmethod - def pkg(klass, name): - return RpmPkg(name) - - @classmethod - def pkg_by_file(klass, path): - find_file = subprocess.Popen(['rpm', '-qf', path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - output = find_file.communicate()[0] - if find_file.returncode: - return None - m = klass._pkg_re.match(output.strip()) - if m: - pkg = m.group('pkg') - else: - pkg = output.strip() - return RpmPkg(pkg) - - @classmethod - def restart_service_cmd(klass, name): - return ['service', name, 'restart'] - - -class FedoraDistro(RedHatDistro): - id = 'Fedora' - - -class RpmPkg(Pkg): - type = 'RPM' - _init_script_re = re.compile('/etc/rc.d/init.d/[\w\-\.]') - _list_contents = [ 'rpm', '-ql', '$pkg_name' ] - - def __init__(self, name): - Pkg.__init__(self, name) - - @property - def shared_objects(self): - if self._shared_objects != None: - return self._shared_objects - - self._shared_objects = [] - contents = self._get_contents() - - for line in contents: - m = self._so_regex.match(line) - if m: - self._shared_objects.append(m.group('so')) - return self._shared_objects - - @property - def services(self): - if self._services != None: - return self._services - - self._services = [] - contents = self._get_contents() - # Only supports sysvinit so far: - for line in contents: - if self._init_script_re.match(line): - self._services.append(os.path.basename(line.strip())) - return self._services - - -def check_maps(procs, shared_objects): - restart_procs = {} - for proc in procs: - for so in shared_objects: - if proc.maps(so): - if restart_procs.has_key(proc.exe): - restart_procs[proc.exe] += [ proc ] - else: - restart_procs[proc.exe] = [ proc ] - continue - return restart_procs - - -def get_all_pids(): - processes = [] - paths = glob.glob('/proc/[0-9]*') - - for path in paths: - p = Process(int(path.rsplit('/')[-1])) - processes.append(p) - - return processes - - -def detect_distro(): - id = None - - if lsb_release: - id = lsb_release.get_distro_information()['ID'] - else: - try: - lsb_cmd = subprocess.Popen(['lsb_release', '--id', '-s'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - output = lsb_cmd.communicate()[0] - if not lsb_cmd.returncode: - id = output.strip() - except OSError: - # id is None in this case - pass - - if id == DebianDistro.id: - return DebianDistro - elif id == FedoraDistro.id: - return FedoraDistro - else: - if os.path.exists('/usr/bin/dpkg'): - logging.warning("Unknown distro but dpkg found, assuming Debian") - return DebianDistro - elif os.path.exists('/bin/rpm'): - logging.warning("Unknown distro but rpm found, assuming Fedora") - return FedoraDistro - else: - return None - - -def write_cmd_file(services, cmd_file, distro): - "Write out commands needed to restart the services to a file" - out = file(cmd_file, 'w') - print >>out, '#! /bin/sh' - for service in services: - logging.debug("Need to restart %s", service) - print >>out, " ".join(distro.restart_service_cmd(service)) - out.close() - os.chmod(cmd_file, 0755) - - -def main(argv): - shared_objects = [] - - parser = OptionParser(usage='%prog [options] pkg1 [pkg2 pkg3 pkg4]') - parser.add_option("--debug", action="store_true", dest="debug", - default=False, help="enable debug output") - parser.add_option("--verbose", action="store_true", dest="verbose", - default=False, help="enable verbose output") - parser.add_option("--restart", action="store_true", dest="restart", - default=False, help="Restart services") - parser.add_option("--print-cmds", dest="print_cmds", - help="Output restart commands to file instead of restarting") - parser.add_option("--apt", action="store_true", dest="apt", default=False, - help="Use in apt pipeline") - - (options, args) = parser.parse_args(argv[1:]) - - if options.debug: - level = logging.DEBUG - elif options.verbose: - level = logging.INFO - else: - level = logging.WARNING - - logging.basicConfig(level=level, - format='%(levelname)s: %(message)s') - - distro = detect_distro() - if not distro: - logging.error("Unsupported Distribution") - return 1 - else: - logging.debug("Detected distribution: '%s'", distro.id) - - if args: - pkgs = [ distro.pkg(arg) for arg in args ] - elif options.apt and distro.has_apt(): - try: - pkgs = distro.read_apt_pipeline() - except PkgError: - logging.error("Can't read apt pipeline") - return 1 - if not pkgs: - return 0 - pkgs = distro.filter_security_updates(pkgs) - logging.debug("Security Upgrades: %s" % pkgs) - else: - parser.print_help() - return 1 - - # Find shared objects of updated packages - for pkg in pkgs: - try: - shared_objects += pkg.shared_objects - except PkgError: - logging.error("Cannot parse contents of %s" % pkg.name) - return 1 - logging.debug("Found shared objects:") - map(lambda x: logging.debug(" %s", x), shared_objects) - - # Find processes that map them - restart_procs = check_maps(get_all_pids(), shared_objects) - logging.debug("Processes that map them:") - map(lambda (x, y): logging.debug(" Exe: %s Pids: %s", x, y), - restart_procs.items()) - - # Find packages that contain the binaries of these processes - pkgs = {} - for proc in restart_procs: - pkg = distro.pkg_by_file(proc) - if not pkg: - logging.warning("No package found for '%s' - restart manually" % proc) - else: - if pkgs.has_key(pkg.name): - pkgs[pkg.name].procs.append(proc) - else: - pkg.procs = [ proc ] - pkgs[pkg.name] = pkg - - logging.info("Packages that ship the affected binaries:") - map(lambda x: logging.info(" Pkg: %s, binaries: %s" % (x.name, x.procs)), - pkgs.values()) - - all_services = set() - try: - for pkg in pkgs.values(): - services = set(pkg.services + distro.pkg_services(pkg)) - services -= set(distro.pkg_service_blacklist(pkg)) - if not services: - logging.warning("No service script found in '%s' for '%s' " - "- restart manually" % (pkg.name, pkg.procs)) - else: - all_services.update(services) - all_services -= distro.service_blacklist - except NotImplementedError: - if level > logging.INFO: - logging.error("Getting Service listing not implemented " - "for distribution %s - rerun with --verbose to see a list" - "of binaries and packages to map a shared objects from %s", - distro.id, args) - return 1 - else: - return 0 - - if options.restart: - if options.print_cmds and all_services: - write_cmd_file(all_services, options.print_cmds, distro) - else: - for service in all_services: - logging.info("Restarting %s" % service) - distro.restart_service(service) - elif all_services: - print "Services that possibly need to be restarted:" - for s in all_services: - print s - - return 0 - -if __name__ == '__main__': - sys.exit(main(sys.argv)) - -# vim:et:ts=4:sw=4:et:sts=4:ai:set list listchars=tab\:»·,trail\:·: diff --git a/whatmaps/__init__.py b/whatmaps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/whatmaps/command.py b/whatmaps/command.py new file mode 100755 index 0000000..98de395 --- /dev/null +++ b/whatmaps/command.py @@ -0,0 +1,611 @@ +#!/usr/bin/python -u +# vim: set fileencoding=utf-8 : +# +# (C) 2010,2014 Guido Günther +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import glob +import os +import logging +import re +import string +import subprocess +import sys +import errno +from optparse import OptionParser +try: + import apt_pkg +except ImportError: + apt_pkg = None +try: + import lsb_release +except ImportError: + lsb_release = None + + +class PkgError(Exception): + pass + + +class Process(object): + """A process - Linux only so far, needs /proc mounted""" + deleted_re = re.compile(r"(?P.*) \(deleted\)$") + + def __init__(self, pid): + self.pid = pid + self.mapped = [] + try: + self.exe = os.readlink('/proc/%d/exe' % self.pid) + m = self.deleted_re.match(self.exe) + if m: + self.exe = m.group('exe') + logging.debug("Using deleted exe %s", self.exe) + if not os.path.exists(self.exe): + logging.debug("%s doesn't exist", self.exe) + self.cmdline = open('/proc/%d/cmdline' % self.pid).read() + except OSError: + self.exe = None + self.cmdline = None + + def _read_maps(self): + """Read the SOs from /proc//maps""" + try: + f = file('/proc/%d/maps' % self.pid) + except IOError as e: + # ignore killed process + if e.errno != errno.ENOENT: + raise + return + for line in f: + try: + so = line.split()[5].strip() + self.mapped.append(so) + except IndexError: + pass + + def maps(self, path): + """Check if the process maps the object at path""" + if not self.mapped: + self._read_maps() + + if path in self.mapped: + return True + else: + return False + + def __repr__(self): + return "" % self.pid + + +class Distro(object): + """ + A distribution + @cvar id: distro id as returned by lsb-release + """ + id = None + service_blacklist = set() + _pkg_services = {} + _pkg_blacklist = {} + + @classmethod + def pkg(klass, name): + """Return package object named name""" + raise NotImplementedError + + @classmethod + def pkg_by_file(klass, path): + """Return package object that contains path""" + raise NotImplementedError + + @classmethod + def restart_service_cmd(klass, service): + """Command to restart service""" + raise NotImplementedError + + @classmethod + def restart_service(klass, service): + """Restart a service""" + subprocess.call(klass.restart_service_cmd(service)) + + @classmethod + def pkg_services(klass, pkg): + """ + List of services that package pkg needs restarted that aren't part + of pkg itself + """ + try: + return klass._pkg_services[pkg.name] + except KeyError: + return [] + + @classmethod + def pkg_service_blacklist(klass, pkg): + """ + List of services in pkg that we don't want to be restarted even when + a binary from this package maps a shared lib that changed. + """ + try: + return klass._pkg_service_blacklist[pkg.name] + except KeyError: + return [] + + @classmethod + def has_apt(klass): + """Does the distribution use apt""" + return False + + +class Pkg(object): + """ + A package in a distribution + @var services: list of services provided by package + @var shared_objects: list of shared objects shipped in this package + @cvar type: package type (e.g. RPM or Debian) + @cvar _so_regex: regex that matches shared objects in the list returned by + _get_contents + @cvar _list_contents: command to list contents of a package, will be passed + to subprocess. "$pkg_name" will be replaced by the package + name. + """ + + type = None + services = None + shared_objects = None + _so_regex = re.compile(r'(?P/.*\.so(\.[^/]*)$)') + _list_contents = None + + def __init__(self, name): + self.name = name + self._services = None + self._shared_objects = None + self._contents = None + + def __repr__(self): + return "<%s Pkg object name:'%s'>" % (self.type, self.name) + + def _get_contents(self): + """List of files in the package""" + if self._contents: + return self._contents + else: + cmd = [ string.Template(arg).substitute(arg, pkg_name=self.name) + for arg in self._list_contents ] + list_contents = subprocess.Popen(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + output = list_contents.communicate()[0] + if list_contents.returncode: + raise PkgError + self.contents = output.split('\n') + return self.contents + + +class DebianDistro(Distro): + "Debian (dpkg) based distribution""" + id = 'Debian' + + _pkg_services = { 'apache2-mpm-worker': [ 'apache2' ], + 'apache2-mpm-prefork': [ 'apache2' ], + 'apache2.2-bin': [ 'apache2' ], + 'dovecot-imapd': [ 'dovecot' ], + 'dovecot-pop3d': [ 'dovecot' ], + 'exim4-daemon-light': [ 'exim4' ], + 'exim4-daemon-heavy': [ 'exim4' ], + 'qemu-system-x86_64': [ 'libvirt-guests' ], + } + + # Per package blacklist + _pkg_service_blacklist = { 'libvirt-bin': [ 'libvirt-guests' ] } + + # Per distro blacklist + service_blacklist = set(['kvm', 'qemu-kvm', 'qemu-system-x86']) + + @classmethod + def pkg(klass, name): + return DebianPkg(name) + + @classmethod + def pkg_by_file(klass, path): + find_file = subprocess.Popen(['dpkg-query', '-S', path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + output = find_file.communicate()[0] + if find_file.returncode: + return None + pkg = output.split(':')[0] + return DebianPkg(pkg) + + @classmethod + def restart_service_cmd(klass, name): + return ['invoke-rc.d', name, 'restart'] + + @classmethod + def has_apt(klass): + return True + + @staticmethod + def read_apt_pipeline(): + whatmaps_enabled = False + + version = sys.stdin.readline().rstrip() + if version != "VERSION 2": + logging.error("Wrong or missing VERSION from apt pipeline\n" + "(is Dpkg::Tools::Options::/usr/bin/whatmaps::Version set to 2?)") + raise PkgError + + while 1: + aptconfig = sys.stdin.readline() + if not aptconfig or aptconfig == '\n': + break + if aptconfig.startswith('Whatmaps::Enable-Restart=') and \ + aptconfig.strip().split('=', 1)[1].lower() in ["true", "1"]: + logging.debug("Service restarts enabled") + whatmaps_enabled = True + + if not whatmaps_enabled: + return None + + pkgs = {} + for line in sys.stdin.readlines(): + if not line: + break + (pkgname, oldversion, compare, newversion, filename) = line.split() + + if filename == '**CONFIGURE**': + if oldversion != '-': # Updates only + pkgs[pkgname] = DebianPkg(pkgname) + pkgs[pkgname].version = newversion + return pkgs + + + @classmethod + def _security_update_origins(klass): + "Determine security update origins from apt configuration" + codename = lsb_release.get_distro_information()['CODENAME'] + def _subst(line): + mapping = {'distro_codename' : codename, + 'distro_id' : klass.id, } + return string.Template(line).substitute(mapping) + + origins = [] + for s in apt_pkg.config.value_list('Whatmaps::Security-Update-Origins'): + (distro_id, distro_codename) = s.split() + origins.append((_subst(distro_id), + _subst(distro_codename))) + logging.debug("Security Update Origins: %s", origins) + return origins + + + @classmethod + def filter_security_updates(klass, pkgs): + """Filter on security updates""" + + apt_pkg.init() + acquire = apt_pkg.Acquire() + cache = apt_pkg.Cache() + + security_update_origins = klass._security_update_origins() + security_updates = {} + + for pkg in pkgs.values(): + cache_pkg = cache[pkg.name] + for cache_version in cache_pkg.version_list: + if pkg.version == cache_version.ver_str: + for pfile, _ in cache_version.file_list: + for origin in security_update_origins: + if pfile.origin == origin[0] and \ + pfile.archive == origin[1]: + security_updates[pkg] = pkg + break + return security_updates + + +class DebianPkg(Pkg): + type = 'Debian' + _init_script_re = re.compile('/etc/init.d/[\w\-\.]') + _list_contents = ['dpkg-query', '-L', '${pkg_name}' ] + + def __init__(self, name): + Pkg.__init__(self, name) + + @property + def shared_objects(self): + if self._shared_objects != None: + return self._shared_objects + + self._shared_objects = [] + contents = self._get_contents() + + for line in contents: + m = self._so_regex.match(line) + if m: + self._shared_objects.append(m.group('so')) + return self._shared_objects + + @property + def services(self): + if self._services != None: + return self._services + + self._services = [] + contents = self._get_contents() + # Only supports sysvinit so far: + for line in contents: + if self._init_script_re.match(line): + self._services.append(os.path.basename(line.strip())) + return self._services + + +class RedHatDistro(Distro): + "RPM based distribution""" + _pkg_re = re.compile(r'(?P[\w\-\+]+)-(?P[\w\.]+)' + '-(?P[\w\.]+)\.(?P.+)') + + @classmethod + def pkg(klass, name): + return RpmPkg(name) + + @classmethod + def pkg_by_file(klass, path): + find_file = subprocess.Popen(['rpm', '-qf', path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + output = find_file.communicate()[0] + if find_file.returncode: + return None + m = klass._pkg_re.match(output.strip()) + if m: + pkg = m.group('pkg') + else: + pkg = output.strip() + return RpmPkg(pkg) + + @classmethod + def restart_service_cmd(klass, name): + return ['service', name, 'restart'] + + +class FedoraDistro(RedHatDistro): + id = 'Fedora' + + +class RpmPkg(Pkg): + type = 'RPM' + _init_script_re = re.compile('/etc/rc.d/init.d/[\w\-\.]') + _list_contents = [ 'rpm', '-ql', '$pkg_name' ] + + def __init__(self, name): + Pkg.__init__(self, name) + + @property + def shared_objects(self): + if self._shared_objects != None: + return self._shared_objects + + self._shared_objects = [] + contents = self._get_contents() + + for line in contents: + m = self._so_regex.match(line) + if m: + self._shared_objects.append(m.group('so')) + return self._shared_objects + + @property + def services(self): + if self._services != None: + return self._services + + self._services = [] + contents = self._get_contents() + # Only supports sysvinit so far: + for line in contents: + if self._init_script_re.match(line): + self._services.append(os.path.basename(line.strip())) + return self._services + + +def check_maps(procs, shared_objects): + restart_procs = {} + for proc in procs: + for so in shared_objects: + if proc.maps(so): + if restart_procs.has_key(proc.exe): + restart_procs[proc.exe] += [ proc ] + else: + restart_procs[proc.exe] = [ proc ] + continue + return restart_procs + + +def get_all_pids(): + processes = [] + paths = glob.glob('/proc/[0-9]*') + + for path in paths: + p = Process(int(path.rsplit('/')[-1])) + processes.append(p) + + return processes + + +def detect_distro(): + id = None + + if lsb_release: + id = lsb_release.get_distro_information()['ID'] + else: + try: + lsb_cmd = subprocess.Popen(['lsb_release', '--id', '-s'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + output = lsb_cmd.communicate()[0] + if not lsb_cmd.returncode: + id = output.strip() + except OSError: + # id is None in this case + pass + + if id == DebianDistro.id: + return DebianDistro + elif id == FedoraDistro.id: + return FedoraDistro + else: + if os.path.exists('/usr/bin/dpkg'): + logging.warning("Unknown distro but dpkg found, assuming Debian") + return DebianDistro + elif os.path.exists('/bin/rpm'): + logging.warning("Unknown distro but rpm found, assuming Fedora") + return FedoraDistro + else: + return None + + +def write_cmd_file(services, cmd_file, distro): + "Write out commands needed to restart the services to a file" + out = file(cmd_file, 'w') + print >>out, '#! /bin/sh' + for service in services: + logging.debug("Need to restart %s", service) + print >>out, " ".join(distro.restart_service_cmd(service)) + out.close() + os.chmod(cmd_file, 0755) + + +def main(argv): + shared_objects = [] + + parser = OptionParser(usage='%prog [options] pkg1 [pkg2 pkg3 pkg4]') + parser.add_option("--debug", action="store_true", dest="debug", + default=False, help="enable debug output") + parser.add_option("--verbose", action="store_true", dest="verbose", + default=False, help="enable verbose output") + parser.add_option("--restart", action="store_true", dest="restart", + default=False, help="Restart services") + parser.add_option("--print-cmds", dest="print_cmds", + help="Output restart commands to file instead of restarting") + parser.add_option("--apt", action="store_true", dest="apt", default=False, + help="Use in apt pipeline") + + (options, args) = parser.parse_args(argv[1:]) + + if options.debug: + level = logging.DEBUG + elif options.verbose: + level = logging.INFO + else: + level = logging.WARNING + + logging.basicConfig(level=level, + format='%(levelname)s: %(message)s') + + distro = detect_distro() + if not distro: + logging.error("Unsupported Distribution") + return 1 + else: + logging.debug("Detected distribution: '%s'", distro.id) + + if args: + pkgs = [ distro.pkg(arg) for arg in args ] + elif options.apt and distro.has_apt(): + try: + pkgs = distro.read_apt_pipeline() + except PkgError: + logging.error("Can't read apt pipeline") + return 1 + if not pkgs: + return 0 + pkgs = distro.filter_security_updates(pkgs) + logging.debug("Security Upgrades: %s" % pkgs) + else: + parser.print_help() + return 1 + + # Find shared objects of updated packages + for pkg in pkgs: + try: + shared_objects += pkg.shared_objects + except PkgError: + logging.error("Cannot parse contents of %s" % pkg.name) + return 1 + logging.debug("Found shared objects:") + map(lambda x: logging.debug(" %s", x), shared_objects) + + # Find processes that map them + restart_procs = check_maps(get_all_pids(), shared_objects) + logging.debug("Processes that map them:") + map(lambda (x, y): logging.debug(" Exe: %s Pids: %s", x, y), + restart_procs.items()) + + # Find packages that contain the binaries of these processes + pkgs = {} + for proc in restart_procs: + pkg = distro.pkg_by_file(proc) + if not pkg: + logging.warning("No package found for '%s' - restart manually" % proc) + else: + if pkgs.has_key(pkg.name): + pkgs[pkg.name].procs.append(proc) + else: + pkg.procs = [ proc ] + pkgs[pkg.name] = pkg + + logging.info("Packages that ship the affected binaries:") + map(lambda x: logging.info(" Pkg: %s, binaries: %s" % (x.name, x.procs)), + pkgs.values()) + + all_services = set() + try: + for pkg in pkgs.values(): + services = set(pkg.services + distro.pkg_services(pkg)) + services -= set(distro.pkg_service_blacklist(pkg)) + if not services: + logging.warning("No service script found in '%s' for '%s' " + "- restart manually" % (pkg.name, pkg.procs)) + else: + all_services.update(services) + all_services -= distro.service_blacklist + except NotImplementedError: + if level > logging.INFO: + logging.error("Getting Service listing not implemented " + "for distribution %s - rerun with --verbose to see a list" + "of binaries and packages to map a shared objects from %s", + distro.id, args) + return 1 + else: + return 0 + + if options.restart: + if options.print_cmds and all_services: + write_cmd_file(all_services, options.print_cmds, distro) + else: + for service in all_services: + logging.info("Restarting %s" % service) + distro.restart_service(service) + elif all_services: + print "Services that possibly need to be restarted:" + for s in all_services: + print s + + return 0 + +def run(): + return(main(sys.argv)) + +if __name__ == '__main__': + sys.exit(main(sys.argv)) + +# vim:et:ts=4:sw=4:et:sts=4:ai:set list listchars=tab\:»·,trail\:·: -- cgit v1.2.3 From 2db247f179c3ab0f350bb3c9848ccc990ad761bc Mon Sep 17 00:00:00 2001 From: Guido Günther Date: Fri, 18 Apr 2014 12:06:46 +0200 Subject: Split out and test Process --- tests/__init__.py | 0 tests/context.py | 53 +++++++++++++++++++++ tests/test_process.py | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ whatmaps/command.py | 52 +-------------------- whatmaps/process.py | 77 +++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 50 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/context.py create mode 100644 tests/test_process.py create mode 100644 whatmaps/process.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/context.py b/tests/context.py new file mode 100644 index 0000000..d859c2f --- /dev/null +++ b/tests/context.py @@ -0,0 +1,53 @@ +# this context.py should be included by all tests +# idea from http://kennethreitz.com/repository-structure-and-python.html + +import os +import shutil +import sys +import tempfile + +import whatmaps + +sys.path.insert(0, os.path.abspath('..')) + +# the top or root dir of the git-buildpackage source tree to be used by tests +projectdir = os.path.dirname(os.path.dirname(os.path.abspath(whatmaps.__file__))) + +_chdir_backup = None +_tmpdirs = [] + +def chdir(dir): + global _chdir_backup + if not _chdir_backup: + _chdir_backup = os.path.abspath(os.curdir) + os.chdir(str(dir)) + +def new_tmpdir(name): + global _tmpdirs + prefix='whatmaps_%s_' % name + tmpdir = TmpDir(prefix) + _tmpdirs.append(tmpdir) + return tmpdir + +def teardown(): + if _chdir_backup: + os.chdir(_chdir_backup) + for tmpdir in _tmpdirs: + tmpdir.rmdir() + del _tmpdirs[:] + +class TmpDir(object): + + def __init__(self, suffix='', prefix='tmp'): + self.path = tempfile.mkdtemp(suffix=suffix, prefix=prefix) + + def rmdir(self): + if self.path and not os.getenv("WHATMAPS_TESTS_NOCLEAN"): + shutil.rmtree(self.path) + self.path = None + + def __repr__(self): + return self.path + + def join(self, *args): + return os.path.join(self.path, *args) diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..da47ebd --- /dev/null +++ b/tests/test_process.py @@ -0,0 +1,124 @@ +# vim: set fileencoding=utf-8 : +# (C) 2014 Guido Günther +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +"""Test L{whatmaps.process} config""" + +import os +import sys +import unittest +import random + +from whatmaps.process import Process + +from . import context + +class TestWhatmapsProcess(unittest.TestCase): + def setUp(self): + self.tmpdir = context.new_tmpdir(__name__) + self.procfs = str(self.tmpdir) + self.pid = random.randint(1, 65535) + self.piddir = os.path.join(self.procfs, str(self.pid)) + os.mkdir(self.piddir) + self.exe = os.path.join(self.piddir, 'exe') + self.cmdline = os.path.join(self.piddir, 'cmdline') + self.maps = os.path.join(self.piddir, 'maps') + self._write_cmdline('doesnotmatter') # Write at least an empty cmdline + self._write_exe_symlink('acommand') + self._write_maps([['f32b43221000-7f32b4522000', + '---p', + '00020000', + 'fe:02', + '1704011', + '/lib/x86_64-linux-gnu/libselinux.so.1'], + ['7f32b4521000-7f32b4623000', + 'r--p', + '00020000', + 'fe:02', + '1704011', + '/lib/x86_64-linux-gnu/libselinux.so.1'], + ]) + + def _write_exe_symlink(self, name): + exe = os.path.join(str(self.tmpdir), name) + os.symlink(exe, self.exe) + + def _write_cmdline(self, text=''): + f = open(self.cmdline, 'w') + f.write(text) + f.close() + + def _write_maps(self, data): + f = open(self.maps, 'w') + f.write('\n'.join([' '.join(r) for r in data])) + f.close() + + def test_nonexistent(self): + """No exe link should create an 'empty' object""" + os.unlink(self.exe) + p = Process(self.pid, self.procfs) + self.assertIsNone(p.exe) + self.assertIsNone(p.cmdline) + + def test_deleted(self): + """Handle symlink to deleted binaries""" + exe = '/does/not/matter' + os.unlink(self.exe) + os.symlink(os.path.join(self.piddir, '%s (deleted)' % exe), + self.exe) + + p = Process(self.pid, procfs=self.procfs) + self.assertEqual(p.exe, exe) + self.assertTrue(p.deleted, True) + self.assertEqual(p.cmdline, 'doesnotmatter') + + def test_existing(self): + p = Process(self.pid, procfs=self.procfs) + exe = os.path.join(str(self.tmpdir), 'acommand') + self.assertEqual(p.exe, exe) + self.assertEqual(p.cmdline, 'doesnotmatter') + self.assertFalse(p.deleted) + self.assertEqual(str(p), + "" % self.pid) + + def test_maps(self): + """Check whether the process maps a shared object at path""" + p = Process(self.pid, procfs=self.procfs) + self.assertFalse(p.maps('/does/not/exist')) + self.assertTrue(p.maps('/lib/x86_64-linux-gnu/libselinux.so.1')) + + def test_no_maps(self): + """Check if we don't fail if the process went away""" + os.unlink(self.maps) + p = Process(self.pid, procfs=self.procfs) + self.assertFalse(p.maps('/does/not/exist')) + self.assertFalse(p.maps('/lib/x86_64-linux-gnu/libselinux.so.1')) + + def test_broken_maps(self): + """Continue on unparseable map file""" + # Provoke index error by to few items in line + self._write_maps([['do', 'few', 'items']]) + p = Process(self.pid, procfs=self.procfs) + self.assertFalse(p.maps('/does/not/exist')) + self.assertFalse(p.maps('/lib/x86_64-linux-gnu/libselinux.so.1')) + + @unittest.skipIf(os.getuid() == 0, "Skip if root") + def test_broken_unreadable_map(self): + """Raise error if map file is unreadable""" + os.chmod(self.maps, 0) + p = Process(self.pid, procfs=self.procfs) + self.assertRaises(IOError, p.maps, '/does/not/exist') + + def tearDown(self): + context.teardown() diff --git a/whatmaps/command.py b/whatmaps/command.py index 98de395..502f7d3 100755 --- a/whatmaps/command.py +++ b/whatmaps/command.py @@ -34,61 +34,13 @@ try: except ImportError: lsb_release = None +from whatmaps.process import Process + class PkgError(Exception): pass -class Process(object): - """A process - Linux only so far, needs /proc mounted""" - deleted_re = re.compile(r"(?P.*) \(deleted\)$") - - def __init__(self, pid): - self.pid = pid - self.mapped = [] - try: - self.exe = os.readlink('/proc/%d/exe' % self.pid) - m = self.deleted_re.match(self.exe) - if m: - self.exe = m.group('exe') - logging.debug("Using deleted exe %s", self.exe) - if not os.path.exists(self.exe): - logging.debug("%s doesn't exist", self.exe) - self.cmdline = open('/proc/%d/cmdline' % self.pid).read() - except OSError: - self.exe = None - self.cmdline = None - - def _read_maps(self): - """Read the SOs from /proc//maps""" - try: - f = file('/proc/%d/maps' % self.pid) - except IOError as e: - # ignore killed process - if e.errno != errno.ENOENT: - raise - return - for line in f: - try: - so = line.split()[5].strip() - self.mapped.append(so) - except IndexError: - pass - - def maps(self, path): - """Check if the process maps the object at path""" - if not self.mapped: - self._read_maps() - - if path in self.mapped: - return True - else: - return False - - def __repr__(self): - return "" % self.pid - - class Distro(object): """ A distribution diff --git a/whatmaps/process.py b/whatmaps/process.py new file mode 100644 index 0000000..3fe23f3 --- /dev/null +++ b/whatmaps/process.py @@ -0,0 +1,77 @@ +#!/usr/bin/python -u +# vim: set fileencoding=utf-8 : +# +# (C) 2010,2014 Guido Günther +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import errno +import logging +import os +import re + +class Process(object): + """A process - Linux only so far, needs /proc mounted""" + deleted_re = re.compile(r"(?P.*) \(deleted\)$") + + def __init__(self, pid, procfs=None): + self.procfs = procfs or '/proc' + self.pid = pid + self.mapped = [] + self.deleted = False + try: + self.exe = os.readlink(self._procpath(str(self.pid), 'exe')) + m = self.deleted_re.match(self.exe) + if m: + self.exe = m.group('exe') + self.deleted = True + logging.error("Using deleted exe %s", self.exe) + if not os.path.exists(self.exe): + logging.debug("%s doesn't exist", self.exe) + self.cmdline = open(self._procpath('%d/cmdline' % self.pid)).read() + except OSError: + self.exe = None + self.cmdline = None + + def _procpath(self, *args): + """ + Return a path relative to the current procfs bsae + """ + return os.path.join(self.procfs, *args) + + def _read_maps(self): + """Read the SOs from /proc//maps""" + try: + f = file(self._procpath('%d/maps' % self.pid)) + except IOError as e: + # ignore killed process + if e.errno != errno.ENOENT: + raise + return + for line in f: + try: + so = line.split()[5].strip() + self.mapped.append(so) + except IndexError: + pass + + def maps(self, path): + """Check if the process maps the object at path""" + if not self.mapped: + self._read_maps() + + return True if path in self.mapped else False + + def __repr__(self): + return "" % self.pid -- cgit v1.2.3 From 98bdda0e2e0fe182857ebb7f56b86193c2a9e7d3 Mon Sep 17 00:00:00 2001 From: Guido Günther Date: Fri, 18 Apr 2014 14:23:48 +0200 Subject: Add manpage in pod format --- man/whatmaps.pod | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 man/whatmaps.pod diff --git a/man/whatmaps.pod b/man/whatmaps.pod new file mode 100644 index 0000000..408e75d --- /dev/null +++ b/man/whatmaps.pod @@ -0,0 +1,43 @@ +=head1 NAME + +whatmaps - Find and restart services after library upgrades + +=head1 SYNOPSIS +=encoding utf8 + +B [--restart] [--print-cmds=I] pkg1 [pkg2 pkg3 ...] + +=head1 DESCRIPTION + +B tries to find a list of services that need to be restarted +after a library upgrade because they map that particular library into +their address space. It does this by extracting a list of shared +objects from the packages given on the command line. It then looks at +the list of running processes to find those that map the shared +objects and uses the distributions package manager to find out the +packages that ship them. It then prints a list of services in these +packages that likely need to be restarted. + +By default it assumes all services in the found packages need a restart but it +also keeps a internal distribution specific list of exceptions. + +If the I<--restart> option is given, the services are restarted without any +prompting and with the I<--prind-cmds> option the restart commands are written to +a file for later execution. + +On Debian systems B can also be run automatically by apt-get. See +L for details. + +=head1 SEE ALSO + +apt(8) + +The latest version is available at +L. + +=head1 AUTHOR + +Guido Günther + +=cut + -- cgit v1.2.3