From cb3b4182ce5b8aefbb82410ac878e942810f3fc7 Mon Sep 17 00:00:00 2001 From: Tomas Babej Date: Thu, 5 Feb 2015 18:47:39 +0100 Subject: [PATCH] ipadiag: Add IPA diagnostics tool --- freeipa.spec.in | 32 ++ install/tools/Makefile.am | 1 + install/tools/ipa-diag | 23 + ipadiag/__init__.py | 22 + ipadiag/base.py | 670 +++++++++++++++++++++++++ ipadiag/doctors/__init__.py | 22 + ipadiag/doctors/plugins/__init__.py | 0 ipadiag/doctors/plugins/dns_doc.py | 122 +++++ ipadiag/doctors/plugins/kerberos_doc.py | 44 ++ ipadiag/doctors/plugins/ldap_doc.py | 139 +++++ ipadiag/doctors/plugins/trust_doc.py | 53 ++ ipadiag/errors.py | 30 ++ ipadiag/pluggable.py | 297 +++++++++++ ipadiag/reporters/__init__.py | 22 + ipadiag/reporters/plugins/__init__.py | 0 ipadiag/reporters/plugins/base.py | 51 ++ ipadiag/reporters/plugins/ca.py | 32 ++ ipadiag/reporters/plugins/config.py | 67 +++ ipadiag/reporters/plugins/dns.py | 32 ++ ipadiag/reporters/plugins/domain.py | 32 ++ ipadiag/reporters/plugins/hostname.py | 34 ++ ipadiag/reporters/plugins/ldap_plugins.py | 182 +++++++ ipadiag/reporters/plugins/ldap_process_size.py | 54 ++ ipadiag/reporters/plugins/replication.py | 83 +++ ipadiag/reporters/plugins/time.py | 34 ++ ipadiag/reporters/plugins/trusts.py | 32 ++ ipadiag/result.py | 146 ++++++ ipadiag/util.py | 127 +++++ ipalib/cli.py | 25 + ipaplatform/base/paths.py | 1 + make-lint | 2 +- setup.py | 5 + 32 files changed, 2415 insertions(+), 1 deletion(-) create mode 100755 install/tools/ipa-diag create mode 100644 ipadiag/__init__.py create mode 100644 ipadiag/base.py create mode 100644 ipadiag/doctors/__init__.py create mode 100644 ipadiag/doctors/plugins/__init__.py create mode 100644 ipadiag/doctors/plugins/dns_doc.py create mode 100644 ipadiag/doctors/plugins/kerberos_doc.py create mode 100644 ipadiag/doctors/plugins/ldap_doc.py create mode 100644 ipadiag/doctors/plugins/trust_doc.py create mode 100644 ipadiag/errors.py create mode 100644 ipadiag/pluggable.py create mode 100644 ipadiag/reporters/__init__.py create mode 100644 ipadiag/reporters/plugins/__init__.py create mode 100644 ipadiag/reporters/plugins/base.py create mode 100644 ipadiag/reporters/plugins/ca.py create mode 100644 ipadiag/reporters/plugins/config.py create mode 100644 ipadiag/reporters/plugins/dns.py create mode 100644 ipadiag/reporters/plugins/domain.py create mode 100644 ipadiag/reporters/plugins/hostname.py create mode 100644 ipadiag/reporters/plugins/ldap_plugins.py create mode 100644 ipadiag/reporters/plugins/ldap_process_size.py create mode 100644 ipadiag/reporters/plugins/replication.py create mode 100644 ipadiag/reporters/plugins/time.py create mode 100644 ipadiag/reporters/plugins/trusts.py create mode 100644 ipadiag/result.py create mode 100644 ipadiag/util.py diff --git a/freeipa.spec.in b/freeipa.spec.in index ab8af302a5..67c5acb9e4 100644 --- a/freeipa.spec.in +++ b/freeipa.spec.in @@ -360,6 +360,23 @@ This package contains tests that verify IPA functionality. %endif # ONLY_CLIENT +%if ! %{ONLY_CLIENT} +%package diag +Summary: IPA diagnostics tool +Requires: %{name}-client = %{version}-%{release} +Requires: %{name}-python = %{version}-%{release} +Conflicts: %{alt_name}-diag +Obsoletes: %{alt_name}-diag < %{version} + +%description diag +IPA is an integrated solution to provide centrally managed Identity (machine, +user, virtual machines, groups, authentication credentials), Policy +(configuration settings, access control information) and Audit (events, +logs, analysis thereof). +This package contains diagnostics tools that verify functionality of IPA +deployment. + +%endif # ONLY_CLIENT %prep %setup -n freeipa-%{version} -q @@ -535,6 +552,10 @@ mkdir -p %{buildroot}%{_sysconfdir}/cron.d (cd %{buildroot}/%{python_sitelib}/ipatests && find . -type f | \ sed -e 's,\.py.*$,.*,g' | sort -u | \ sed -e 's,\./,%%{python_sitelib}/ipatests/,g' ) >tests-python.list + +(cd %{buildroot}/%{python_sitelib}/ipadiag && find . -type f | \ + sed -e 's,\.py.*$,.*,g' | sort -u | \ + sed -e 's,\./,%%{python_sitelib}/ipadiag/,g' ) >diag-python.list %endif # ONLY_CLIENT %clean @@ -991,6 +1012,17 @@ fi %{_mandir}/man1/ipa-test-task.1.gz %endif # ONLY_CLIENT +%if ! %{ONLY_CLIENT} +%files diag -f diag-python.list +%defattr(-,root,root,-) +%dir %{python_sitelib}/ipadiag +%dir %{python_sitelib}/ipadiag/reporters +%dir %{python_sitelib}/ipadiag/reporters/plugins +%dir %{python_sitelib}/ipadiag/doctors +%dir %{python_sitelib}/ipadiag/doctors/plugins +%{_sbindir}/ipa-diag +%endif # ONLY_CLIENT + %changelog * Tue Nov 26 2013 Petr Viktorin - __VERSION__-__RELEASE__ - Remove changelog. The history is kept in Git, downstreams have own logs. diff --git a/install/tools/Makefile.am b/install/tools/Makefile.am index 7212dabdbf..dae54bd1ee 100644 --- a/install/tools/Makefile.am +++ b/install/tools/Makefile.am @@ -29,6 +29,7 @@ sbin_SCRIPTS = \ ipa-advise \ ipa-cacert-manage \ ipa-winsync-migrate \ + ipa-diag \ $(NULL) EXTRA_DIST = \ diff --git a/install/tools/ipa-diag b/install/tools/ipa-diag new file mode 100755 index 0000000000..3adb20e856 --- /dev/null +++ b/install/tools/ipa-diag @@ -0,0 +1,23 @@ +#! /usr/bin/python2 -E +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.base import IpaDiagnose + +IpaDiagnose.run_cli() diff --git a/ipadiag/__init__.py b/ipadiag/__init__.py new file mode 100644 index 0000000000..4a24183027 --- /dev/null +++ b/ipadiag/__init__.py @@ -0,0 +1,22 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2013 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +""" +Base subpackage for ipa-diag related code. +""" diff --git a/ipadiag/base.py b/ipadiag/base.py new file mode 100644 index 0000000000..4718573cdf --- /dev/null +++ b/ipadiag/base.py @@ -0,0 +1,670 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 itertools +import krbV +import json +import os + +from ipalib import api +from ipalib import cli +from ipalib.util import cachedproperty +from ipapython import admintool +from ipapython.ipa_log_manager import log_mgr + +from ipadiag import util +from ipadiag.pluggable import api as diag_api +from ipadiag.result import HostDiagnosticsResult, DomainDiagnosticsResult + +# Detect if we're running on IPA master + +IN_SERVER = False + +try: + from ipaserver.plugins import ldap2 + IN_SERVER = True +except ImportError: + pass + + +class IpaDiagnose(admintool.AdminTool): + """ + Diagnostics tool that reports information about given FreeIPA deployment + and performs diagnostics checks upon it. + """ + + command_name = 'ipa-diagnose' + usage = "%prog" + description = "Provides diagnosis for the given FreeIPA deployment." + + def __init__(self, options, args): + super(IpaDiagnose, self).__init__(options, args) + + # Option parsing and validation + + @classmethod + def add_options(cls, parser): + """ + Setup option handling via IPAOptParser, which is a wrapper + around optparse module. + """ + + super(IpaDiagnose, cls).add_options(parser) + + parser.add_option( + "--all-servers", + dest="diagnose_servers", action="store_true", default=False, + help="automatically diagnose all IPA servers") + + parser.add_option( + "--domain", + dest="diagnose_domain", action="store_true", default=False, + help="automatically diagnose whole IPA domain, including clients") + + parser.add_option( + "--skip-hosts", + dest="skip_hosts", action='append', + help="hosts to be skipped during the check") + + parser.add_option( + "--check-hosts", + dest="check_hosts", action="append", default=False, + help="hosts to be checked during the check") + + parser.add_option( + "--advice", + dest="advice", action="store_true", default=False, + help="Generate remediation advice." + "on other remote machines") + + parser.add_option( + "--json-only", + dest="json_only", action="store_true", default=False, + help="Produce structured output only") + + def validate_options(self): + """ + Validates the options passed by the user and terminates the tool + in case of validation error. + """ + + # Check the rules according to default admintool implementation + super(IpaDiagnose, self).validate_options(needs_root=False) + + # The --advice and --json-only exclude themselves + if self.options.advice and self.options.json_only: + self.option_parser.error("Both --json-only and --advice cannot " + "be specified.") + + + def run(self): + """ + Main body of the diagnosis tool. + """ + + super(IpaDiagnose, self).run() + + # Initialize the server api + api.bootstrap(in_server=False, context='server') + api.finalize() + + # Initialize the diagnostics api + diag_api.bootstrap(in_server=False, context='server') + diag_api.finalize() + + # Establish LDAP connection + if os.geteuid() == 0: + api.Backend.ldap2.connect(autobind=True) + else: + ctx = krbV.default_context() + ccache = ctx.default_ccache() + api.Backend.ldap2.connect(ccache) + + if not self.options.verbose: + # Do not print connection information by default + logger_name = r'ipa\.ipalib\.plugins\.rpcclient' + log_mgr.configure(dict(logger_regexps=[(logger_name, 'warning')])) + + # Initialize the plugin proxy, output handler and the runner itself + plugins = DiagnosePluginProxy() + runner = DiagnoseRunner(plugins, self.options) + output = DiagnoseOutputHandler(plugins, self.options) + + # Collect data with runner and handle output + local_results, domain_results = runner.main() + output.main(local_results, domain_results) + + def log_success(self): + # Overriden base admintool.AdminTool method. + # Silence unnecessary status message upon success. + pass + + +class DiagnosePluginProxy(object): + """ + Plugin proxy that provides access to all diagnostics plugin. + """ + + reports = None + host_reports = None + domain_reports = None + + # Reporter and doctor access properties + + def __init__(self): + # Provide the plugins with convenient api and ldap instances + self.initialize_plugins() + + @property + def reporters(self): + """ + Return iterator over instances of all Reporter plugins. + """ + return dict(diag_api.Reporter.__todict__()).values() + + @property + def doctors(self): + """ + Return iterator over instances of all Doctor plugins. + """ + return dict(diag_api.Doctor.__todict__()).values() + + @property + def domain_reporters(self): + """ + Return iterator over instances of all DomainReporter plugins. + """ + return dict(diag_api.DomainReporter.__todict__()).values() + + @property + def domain_doctors(self): + """ + Return iterator over instances of all DomainDoctors plugins. + """ + return dict(diag_api.DomainDoctor.__todict__()).values() + + @property + def plugins(self): + """ + Return iterator over instances of all plugins. + """ + return itertools.chain(self.reporters, self.doctors, + self.domain_reporters, self.domain_doctors) + + + def filter_applicable_only(self, plugins, *args, **kwargs): + """ + Generic plugin filter method. + """ + + # Generate a sorted interable of available plugins of given type + plugins_sorted = sorted(plugins, + key=lambda x: (x.group, x.pretty_name)) + + # Filter out the non-applicable instances + return [plugin for plugin in plugins_sorted + if plugin.is_applicable(*args, **kwargs)] + + + @cachedproperty + def applicable_reporters(self): + """ + Property that yields all the available reporters, filtering out + those that are not applicable to the given host. + """ + + return self.filter_applicable_only(self.reporters) + + @cachedproperty + def applicable_doctors(self): + """ + Property that yields all the available doctors, filtering out + those that are not applicable to the given host. + """ + + if self.reports is None: + raise ValueError("Applicable doctors cannot be accessed unless " + "proxy has received the reports.") + + return self.filter_applicable_only(self.doctors, self.reports) + + @cachedproperty + def applicable_domain_reporters(self): + """ + Property that yields all the available reporters, filtering out + those that are not applicable to the given host. + """ + if self.reports is None: + raise ValueError("Applicable doctors cannot be accessed unless " + "proxy has received the host reports.") + + return self.filter_applicable_only(self.domain_reporters, self.host_reports) + + @cachedproperty + def applicable_domain_doctors(self): + """ + Property that yields all the available doctors, filtering out + those that are not applicable to the given host. + """ + + if self.host_reports is None or self.domain_reports is None: + raise ValueError("Applicable doctors cannot be accessed unless " + "proxy has received both the host and domain " + "reports.") + + return self.filter_applicable_only(self.domain_doctors, + self.host_reports, self.domain_reports) + + def initialize_plugins(self): + """ + Intialize the api and ldap attributes of the plugins. + """ + + for plugin in self.plugins: + if IN_SERVER: + # Set ldap connection only if on master + plugin.initialize(api=api, ldap=api.Backend.ldap2) + else: + plugin.initialize(api=api) + + +class DiagnoseOutputHandler(object): + # Output related methods + + def __init__(self, plugins, options): + # Set the output helper instance + self.textui = cli.textui() + self.plugins = plugins + self.options = options + + def print_welcome(self): + # Show the heading + self.textui.print_h1('IPA Diagnostics report') + + def print_reporters(self, results): + """ + Print the results of the host-level reporters, grouped. + """ + + self.textui.print_h2('System information') + + reports = results.reports + + # Group reporters by their report group + reporters_grouped = itertools.groupby( + self.plugins.applicable_reporters, key=lambda x: x.group) + + for group, reporters in reporters_grouped: + self.textui.print_indented(group + ":", indent=2) + + for reporter in reporters: + reporter.print_formatted(self.textui, reports[reporter.name]) + + print('') + + def print_doctors(self, results): + """ + Print the results of the host-level doctors, grouped. + """ + + self.textui.print_h2('System health check') + + checks = results.checks + + # Group doctors by their report group + doctors_grouped = itertools.groupby( + self.plugins.applicable_doctors, key=lambda x: x.group) + + for group, doctors in doctors_grouped: + self.textui.print_indented(group + ":", indent=2) + + for doctor in doctors: + doctor.print_formatted(self.textui, checks[doctor.name]) + + print('') + + def print_domain_reporters(self, results): + """ + Print the results of the domain-level reporters, grouped. + """ + + self.textui.print_h2('Domain information') + + reports = results.reports + + reporters_grouped = itertools.groupby( + self.plugins.applicable_domain_reporters, key=lambda x: x.group) + + for group, reporters in reporters_grouped: + self.textui.print_indented(group + ":", indent=2) + + for reporter in reporters: + reporter.print_formatted(self.textui, reports[reporter.name]) + + print('') + + def print_domain_doctors(self, results): + """ + Print the results of the domain-level doctors, grouped. + """ + + self.textui.print_h2('Domain health check') + + checks = results.checks + + # Group doctors by their report group + doctors_grouped = itertools.groupby( + self.plugins.applicable_domain_doctors, key=lambda x: x.group) + + for group, doctors in doctors_grouped: + self.textui.print_indented(group + ":", indent=2) + + for doctor in doctors: + doctor.print_formatted(self.textui, checks[doctor.name]) + + print('') + + def print_json_only(self, local_results, domain_results): + """ + Produces the JSON output, suitable for further processing. + """ + + data_dict = { + 'reports': {key: value.to_dict() + for key, value in local_results.reports.iteritems()}, + 'checks': {key: value.to_dict() + for key, value in local_results.checks.iteritems()}, + } + + # If there is any domain data available, include it in the report + if domain_results is not None: + domain_data_dict = { + 'domain_reports': {key: value.to_dict() + for key, value in domain_results.reports.iteritems()}, + 'domain_checks': {key: value.to_dict() + for key, value in domain_results.checks.iteritems()}, + } + + data_dict.update(domain_data_dict) + + # Serialize to json and print + print(json.dumps(data_dict)) + + def offer_advice(self, local_checks, domain_checks): + """ + Generates and displays remediation advice. + """ + + self.textui.print_h2('Remediation advice') + + # Filter out only failed checks + failed_local_doctor = [ + (name, result) + for name, result in local_checks.iteritems() + if not result.status + ] + + failed_domain_doctor = [ + (name, result) + for name, result in domain_checks.iteritems() + if not result.status + ] + + # Print a remediation advice for each failed Doctor + for name, check in failed_local_doctor: + doctor = diag_api.Doctor[name] + advice = doctor.get_advice(check.error_key) + self.print_advice(doctor.pretty_name, advice) + + for name, check in failed_domain_doctor: + doctor = diag_api.DomainDoctor[name] + advice = doctor.get_advice(check.error_key) + self.print_advice(doctor.pretty_name, advice) + + def print_advice(self, name, advice): + """ + Prints the advice of with the given prefix. Makes sure lines are properly + aligned. + """ + + # If no advice is generated, do not print anything. + if advice is None: + return + + self.textui.print_indented(name + ":", indent=2) + self.textui.print_indented(advice, indent=3) + + def main(self, local_results, domain_results): + # Welcome the user! + if self.options.json_only: + self.print_json_only(local_results, domain_results) + else: + # Print hosts reporters and doctors result in any case + self.print_welcome() + self.print_reporters(local_results) + self.print_doctors(local_results) + + # Display domain section only if any results were generated + if domain_results is not None: + self.print_domain_reporters(domain_results) + self.print_domain_doctors(domain_results) + + # Offer advice only if requested + if self.options.advice: + self.offer_advice( + local_results.checks, + domain_results.checks if domain_results is not None else {} + ) + + +class DiagnoseRunner(object): + """ + DiagnoseRunner takes care of the plugin execution and data collection. + """ + + def __init__(self, plugins, options): + self.textui = cli.textui() + self.plugins = plugins + self.options = options + + def perform_diagnosis(self): + """ + Performs both the host and domain level diagnosis + and returns the result as a tuple. + """ + + # Collect and let proxy know about the results + self.reports = self.collect_local_reports() + self.plugins.reports = self.reports + + # Perform the checks + self.checks = self.collect_local_checks(self.reports) + + host_result = HostDiagnosticsResult(self.reports, self.checks) + domain_result = self.domain_diagnosis() + + return host_result, domain_result + + def domain_diagnosis(self): + """ + Performs both the host and domain level diagnosis + and returns the result as a tuple. + """ + + host_data_collection = self.collect_host_reports() + if host_data_collection is None: + return None + + host_reports, host_checks, unreachable = host_data_collection + domain_reports = self.collect_domain_reports(host_reports) + self.plugins.host_reports = host_reports + self.plugins.domain_reports = domain_reports + + # Perform the checks + domain_checks = self.collect_domain_checks(host_reports, domain_reports) + + return DomainDiagnosticsResult(domain_reports, domain_checks, + unreachable) + + def collect_host_reports(self): + """ + Collects reports from remote hosts. + """ + + hosts_to_diagnose = set() + + # Build a set of hosts to diagnose + if self.options.diagnose_domain: + hosts_to_diagnose |= util.get_hosts_fqdn(api, api.Backend.ldap2) + elif self.options.diagnose_servers: + hosts_to_diagnose |= util.get_masters_fqdn(api, api.Backend.ldap2) + else: + return None + + host_reports = dict() + host_checks = dict() + + unreachable = [] + + # Setup parameters for the progress bar + step = 0 + steps = len(hosts_to_diagnose) + label = "Collecting remote data:" + + # We deliberately do not execute the jobs in parallel. + #print hosts_to_diagnose + for host in hosts_to_diagnose: + self.update_bar(step, steps, label) + step += 1 + + if host == api.env.host: + host_reports[host] = self.reports + host_checks[host] = self.checks + continue + else: + pass + + try: + remote_results = self.collect_host_results(host) + + if remote_results is None: + unreachable.append(host) + else: + host_reports[host] = remote_results.reports + host_checks[host] = remote_results.checks + + except Exception: + # If anything went wrong, do not abort + pass + + self.update_bar(step, steps, label) + + return host_reports, host_checks, unreachable + + def collect_host_results(self, host): + """ + Collects host results from one host. + """ + + # Fist try to execute the command with sudo + result = util.execute_remote( + host, + ['sudo', '/usr/sbin/ipa-diag', '--json-only'] + ) + + # After that, use ordinary privileges + if result.returncode != 0: + result = util.execute_remote( + host, + ['/usr/sbin/ipa-diag', '--json-only'] + ) + #print result + + if result.returncode != 0: + return None + + # Parse the obtained data + remote_data = json.loads(result.stdout.strip()) + + return HostDiagnosticsResult(remote_data['reports'], + remote_data['checks']) + + def update_bar(self, step, steps, label): + """ + Update the progressbar. No-op if in json-only mode. + """ + if not self.options.json_only: + self.textui.display_progressbar(step, steps, prefix=label, size=20) + + def generic_result_collector(self, plugins, exec_method, label, *args): + """ + Generic method to collect results from plugin execution + """ + + # Setup progressbar + result_collection = {} + steps = len(list(plugins)) + step = 0 + + self.update_bar(step, steps, label) + + # Evaluate each plugin + for plugin in plugins: + plugin_executor = getattr(plugin, exec_method) + result_collection[plugin.name] = plugin_executor(*args) + step = step + 1 + self.update_bar(step, steps, label) + + + return result_collection + + def collect_local_reports(self): + return self.generic_result_collector( + self.plugins.applicable_reporters, + 'report_safe', + 'Collecting reports:' + ) + + def collect_local_checks(self, reports): + return self.generic_result_collector( + self.plugins.applicable_doctors, + 'check_safe', + 'Performing checks:', + reports + ) + + def collect_domain_reports(self, host_reports): + return self.generic_result_collector( + self.plugins.applicable_domain_reporters, + 'report_safe', + 'Collecting domain reports:', + host_reports + ) + + def collect_domain_checks(self, host_reports, domain_reports): + return self.generic_result_collector( + self.plugins.applicable_domain_doctors, + 'check_safe', + 'Performing domain checks:', + host_reports, + domain_reports + ) + + def main(self): + return self.perform_diagnosis() diff --git a/ipadiag/doctors/__init__.py b/ipadiag/doctors/__init__.py new file mode 100644 index 0000000000..e94c1173fe --- /dev/null +++ b/ipadiag/doctors/__init__.py @@ -0,0 +1,22 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +""" +Subpackage for IPA diag's doctors. +""" diff --git a/ipadiag/doctors/plugins/__init__.py b/ipadiag/doctors/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ipadiag/doctors/plugins/dns_doc.py b/ipadiag/doctors/plugins/dns_doc.py new file mode 100644 index 0000000000..5230ede347 --- /dev/null +++ b/ipadiag/doctors/plugins/dns_doc.py @@ -0,0 +1,122 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 dns +import dns.resolver + +from dns.exception import DNSException +from ipadiag.errors import DiagException + +from ipapython.dn import DN +from ipadiag.pluggable import Doctor, register +from ipadiag.util import get_masters_fqdn + +SERVICE_RECORDS = [ + u"_ldap._tcp", + u"_kerberos._tcp", + u"_kerberos._udp", + u"_kerberos-master._tcp", + u"_kerberos-master._udp", + u"_kpasswd._tcp", + u"_kpasswd._udp", +] + + +class check_master_records_base(Doctor): + """ + Base class for Doctors which check master records. + """ + + group = 'DNS' + record_type = None + + def check(self, reports): + fqdns = get_masters_fqdn(self.api, self.ldap) + failed = [] + + for master in [fqdn + '.' for fqdn in fqdns]: + try: + dns.resolver.query(master, self.record_type) + except DNSException: + failed.append(master) + if failed: + raise DiagException( + message=', '.join(failed), + key='failed-resolving' + ) + + +@register() +class check_master_a_records(check_master_records_base): + """ + Checks if all IPA masters have A records. + """ + + cli_name = 'IPA masters A records resolvable' + record_type = 'A' + + +@register() +class check_master_aaaa_records(check_master_records_base): + """ + Checks if all IPA masters have AAAA records. + """ + + cli_name = 'IPA masters AAAA records resolvable' + record_type = 'AAAA' + + +@register() +class check_ipa_service_records(Doctor): + """ + Checks if IPA domain has valid service records. + """ + + group = 'DNS' + cli_name = 'IPA domain service records correct' + + def check(self, reports): + fqdns = [fqdn + '.' + for fqdn in get_masters_fqdn(self.api, self.ldap)] + failed = [] + + # Check each service + for service_record in SERVICE_RECORDS: + try: + record = "{0}.{1}".format(service_record, self.api.env.domain) + result = dns.resolver.query(record, 'SRV') + + for answer in result.rrset: + if answer.target.to_text() not in fqdns: + # In case no answer matches a master, raise an error + raise DiagException( + message="{0} does not point to IPA master" + .format(service_record), + key='not-ipa-master') + + except DNSException: + # If lookup failed, make a note + failed.append(service_record) + + # If any lookup failed, raise a DiagException + if failed: + raise DiagException( + message=', '.join(failed), + key='failed-resolving' + ) diff --git a/ipadiag/doctors/plugins/kerberos_doc.py b/ipadiag/doctors/plugins/kerberos_doc.py new file mode 100644 index 0000000000..9f33f21eff --- /dev/null +++ b/ipadiag/doctors/plugins/kerberos_doc.py @@ -0,0 +1,44 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipalib import api +from ipaplatform.paths import paths +from ipadiag.pluggable import Doctor, register + +@register() +class obtain_host_tgt(Doctor): + """ + Tries to obtain host Ticket Granting Ticket. + """ + + group = 'System' + cli_name = 'Obtain host TGT' + requires_root = True + + def check(self, reports): + host_principal = 'host/{0}@{1}'.format(api.env.host, api.env.realm) + + # Try to obtain the host TGT + result = self.run([ + paths.KINIT, + '-k', '-t', paths.KRB5_KEYTAB, + host_principal + ]) + + return result.returncode == 0 diff --git a/ipadiag/doctors/plugins/ldap_doc.py b/ipadiag/doctors/plugins/ldap_doc.py new file mode 100644 index 0000000000..8f105ca298 --- /dev/null +++ b/ipadiag/doctors/plugins/ldap_doc.py @@ -0,0 +1,139 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 tempfile + +from ipalib import api +from ipaplatform.paths import paths +from ipadiag.pluggable import Doctor, register +from ipadiag.errors import DiagException + + +@register() +class check_max_agreements(Doctor): + """ + Checks that host does not have more than recommended + number of replication agreements. + """ + + group = 'LDAP' + cli_name = 'Number of replication agreements' + requires_root = True + server_only = True + + def is_applicable(self, reports): + # This doctor should not be applicable if the number of agreements + # could not be determined + + agreements_count = reports.get('replication_agreements_count') + return agreements_count is not None and agreements_count.status is True + + def check(self, reports): + # Get the number of agreemets from reports + agreements = int(reports['replication_agreements_count'].value) + + # More than 4 agreements per host are not recommended + if agreements > 4: + raise DiagException( + message="{0} agreements, at most 4 recommended." + .format(agreements)) + + +@register() +class check_masters_readability(Doctor): + """ + Tries to access LDAP service on each host + it has replication agreement with using + the DS keytab. + """ + + group = 'LDAP' + cli_name = 'Check replica readability' + requires_root = True + server_only = True + + def check_host_ldap_readability(self, host): + """ + Checks if LDAP server is readable with current + credentials by performing a dummy search. + """ + + result = self.run( + ['ldapsearch', + '-Y', 'GSSAPI', + '-h', host, + '-b', '', + '-s', 'base'], + env=self.temp_ccache_env, + raiseonerr=False) + + return result + + def check(self, reports): + # Generated a named temporary file, which is deleted + # when the object is destroyed + ccache = tempfile.NamedTemporaryFile() + self.temp_ccache_env = {'KRB5CCNAME': ccache.name} + + # Obtain a ticket using a DS keytab + # The root permissions are necessary for access + # to this file + result = self.run(['kinit', + '-kt', '/etc/dirsrv/ds.keytab', + 'ldap/%s' % api.env.host], + env=self.temp_ccache_env, + raiseonerr=False) + if result.returncode != 0: + raise DiagException("Unable to kinit using DS keytab.", key="kinit-unable") + + + # Run sanity-check search against own LDAP server + result = self.check_host_ldap_readability(api.env.host) + if result.returncode != 0: + raise DiagException("master unreachable", key="master-unreachable") + + # Verify we can read any replica + replicas_raw = reports.get('replication_agreements_list', []).value.split(',') + replicas = [ + replica.strip() + for replica in replicas_raw + ] + + # Check if we can read all the replicas we have replication agreement + # with + for replica in replicas: + result = self.check_host_ldap_readability(replica) + if result.returncode != 0: + raise DiagException("%s unreachable" % replica, + key="replica-unreachable") + + def get_advice(self, key): + if key == 'master-unreachable': + return ("Local LDAP service is unreadable using the DS keytab. " + "Check if the dirsrv process is running and firewall " + "open.") + elif key == 'replica-unreachable': + return ("Remote replica LDAP is unreachable but local LDAP " + "service is. Please check that LDAP service on remote " + "replica is running and firewall open on port 389.") + elif key == 'kinit-unable': + kvno_command = "# kvno -k /etc/dirsrv/ds.keytab ldap/%s" % api.env.host + return ("Unable to kinit using DS keytab. Check Kerberos server. " + "Additionally, keytab kvno of /etc/dirsrv/ds.keytab " + "might be stale. Check with: %s" % kvno_command) diff --git a/ipadiag/doctors/plugins/trust_doc.py b/ipadiag/doctors/plugins/trust_doc.py new file mode 100644 index 0000000000..0fe63eb9df --- /dev/null +++ b/ipadiag/doctors/plugins/trust_doc.py @@ -0,0 +1,53 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipalib import api +from ipaplatform.paths import paths +from ipadiag.pluggable import Doctor, register + + +@register() +class ad_service_records(Doctor): + """ + Checks that DNS service records are resolvable from IPA side. + """ + + group = 'Trust' + cli_name = 'Verify IPA LDAP SRV records' + server_only = True + + def is_applicable(self, reports): + # If trusts are not enabled, bail out + if reports.get('trusts_enabled').value != True: + return False + + return super(ad_service_records, self).is_applicable(reports) + + def check(self, reports): + ipa_srv_record = '_ldap._tcp.{0}'.format(api.env.realm.lower()) + + # Try to obtain the host TGT + result = self.run([ + paths.DIG, + "+noall", "+answer", + "SRV", + ipa_srv_record + ]) + + return ipa_srv_record in result.stdout diff --git a/ipadiag/errors.py b/ipadiag/errors.py new file mode 100644 index 0000000000..14309bfa88 --- /dev/null +++ b/ipadiag/errors.py @@ -0,0 +1,30 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + + +class DiagException(Exception): + """ + Diagnose-specific exception class. Accepts a optional + argument key, which serves as a identifier of the root + cause of the failure. + """ + + def __init__(self, message, key=None): + super(DiagException, self).__init__(message) + self.key = key diff --git a/ipadiag/pluggable.py b/ipadiag/pluggable.py new file mode 100644 index 0000000000..10c5d56171 --- /dev/null +++ b/ipadiag/pluggable.py @@ -0,0 +1,297 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 abc +import collections +import os + +from ipalib.plugable import Plugin, Registry, API +from ipapython import ipautil + +from ipadiag.result import ReporterResult, DoctorResult +from ipadiag.errors import DiagException +from ipadiag.util import CommandResult + + +register = Registry() + +def prepare_for_output(value): + """ + Simple output format handler helper. + """ + + if isinstance(value, bool) or value is None: + return value + elif isinstance(value, list): + return ', '.join(value) + else: + return str(value) + + +class BaseDiagnosePlugin(Plugin): + """ + Base class for any diagnosis plugin. + """ + + requires_root = False + + cli_name = None + group = 'Other' + + @property + def pretty_name(self): + """ + Returns a CLI friendly name. + """ + + return self.cli_name or self.__class__.__name__ + + def print_formatted(self, textui, result): + """ + Formats the result for output and prints it. + Plugin instances can override this method to generate + custom output. + """ + + template = "{0}: {1}{2}" + data = ( + self.pretty_name, + '[Fail] ' if result.status is False else '', + result.value, + ) + + # Fill in the template + text = template.format(*data) + + # Print it out + textui.print_indented(text, indent=3) + + def run(self, args, **kwargs): + """ + Executes the specified command, returning a CommandResult instance. + """ + + result = ipautil.run(args, **kwargs) + return CommandResult(*result) + + @property + def api(self): + return self._api + + def initialize(self, api, ldap=None): + """ + Initializes the API and LDAP attributes on the plugin. + """ + + self._api = api + self.ldap = ldap + + +class BaseHostDiagnosePlugin(BaseDiagnosePlugin): + """ + Base class for host diagnosis plugins. + """ + + server_only = False + client_only = False + + def is_applicable(self): + """ + Detects whether diagnosis should be run on the instance. + """ + + # Check if the current reporter/doctor requires root + if self.requires_root and os.geteuid() != 0: + return False + + if self.server_only and self.api.env.context != "server": + return False + + if self.client_only and self.api.env.context == "server": + return False + + return True + + +class BaseDomainDiagnosePlugin(BaseDiagnosePlugin): + """ + Base class for domain diagnosis plugins. + """ + + def is_applicable(self, host_reports): + """ + Detects whether diagnosis should be run on the instance. + """ + + # Check if the current reporter/doctor requires root + if self.requires_root and os.geteuid() != 0: + return False + + if self.api.env.context != "server": + return False + + return True + + +class BaseReporter(object): + """ + Base class for reporters plugins. + """ + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def report(self): + """ + Main body of the reporter script. + """ + raise NotImplementedError + + def report_safe(self, *args): + """ + Wrapper around the report method, which ensures that a proper + ReporterResult is returned under all circumstances. + """ + + try: + result = self.report(*args) + + # If this is not a instance of ReporterResult, we need to format it + if not isinstance(result, ReporterResult): + result = ReporterResult(True, value=prepare_for_output(result)) + except DiagException as diag_exc: + # For DiagException use specialized constructor + result = ReporterResult.from_diagnose_exception(diag_exc) + except Exception as exception: + result = ReporterResult(False, str(exception), error_key='unknown-error') + + return result + + +class BaseDoctor(object): + """ + Base class for doctor plugins. + """ + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def check(self, reports): + """ + Main body of the doctor script. + """ + raise NotImplementedError + + def check_safe(self, reports, *args): + """ + Wrapper around the check method, which ensures + that a proper DoctorResult object is returned. + """ + + try: + result = self.check(reports, *args) + if not isinstance(result, DoctorResult): + result = DoctorResult( + status=True, + value=prepare_for_output(result) + ) + except DiagException as diag_exc: + result = DoctorResult.from_diagnose_exception( + diag_exc, + self.get_advice(diag_exc.key), + ) + except Exception as exception: + result = DoctorResult(False, str(exception), + error_key='unexpected') + + return result + + def is_applicable(self, reports): + """ + Detects whether diagnosis should be run on the instance. + """ + + return super(BaseDoctor, self).is_applicable() + + def get_advice(self, key): + """ + Generates advice for given error key. + """ + + return None + + def print_formatted(self, textui, result): + if result.value is True or result.value is None: + result_formatted = DoctorResult( + result.status, + "[Pass]", + result.error_key, + result.advice + ) + else: + result_formatted = result + + return super(BaseDoctor, self).print_formatted(textui, result_formatted) + + +@register.base() +class Reporter(BaseReporter, BaseHostDiagnosePlugin): + """ + Host level reporter. + """ + pass + + +@register.base() +class Doctor(BaseDoctor, BaseHostDiagnosePlugin): + """ + Host level doctor. + """ + pass + + +@register.base() +class DomainReporter(BaseReporter, BaseDomainDiagnosePlugin): + """ + Domain level reporter. + """ + + def is_applicable(self, host_reports): + """ + Detects whether diagnosis should be run on the instance. + """ + + return super(DomainReporter, self).is_applicable(host_reports) + + +@register.base() +class DomainDoctor(BaseDoctor, BaseDomainDiagnosePlugin): + """ + Domain level doctor. + """ + + def is_applicable(self, host_reports, domain_reports): + """ + Detects whether diagnosis should be run on the instance. + """ + + return super(DomainDoctor, self).is_applicable(host_reports) + + +api = API((Reporter, Doctor, DomainReporter, DomainDoctor), ('ipadiag/reporters/plugins', 'ipadiag/doctors/plugins')) diff --git a/ipadiag/reporters/__init__.py b/ipadiag/reporters/__init__.py new file mode 100644 index 0000000000..bfa2cdf602 --- /dev/null +++ b/ipadiag/reporters/__init__.py @@ -0,0 +1,22 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +""" +Subpackage for IPA diag's reporters. +""" diff --git a/ipadiag/reporters/plugins/__init__.py b/ipadiag/reporters/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ipadiag/reporters/plugins/base.py b/ipadiag/reporters/plugins/base.py new file mode 100644 index 0000000000..b260352205 --- /dev/null +++ b/ipadiag/reporters/plugins/base.py @@ -0,0 +1,51 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.pluggable import Reporter, register +from ipapython.dn import DN +from ipalib import errors + + +class service_enabled(Reporter): + """ + Reports the enablement of a particular service. + """ + + cli_name = 'Enabled' + server_only = True + service_name = None + + def report(self): + assert self.service_name is not None + + service_dn = DN( + ('cn', self.service_name), + ('cn', self.api.env.host), + ('cn', 'masters'), + ('cn', 'ipa'), + ('cn', 'etc'), + self.api.env.basedn + ) + + try: + self.ldap.get_entry(service_dn) + except errors.NotFound: + return False + + return True diff --git a/ipadiag/reporters/plugins/ca.py b/ipadiag/reporters/plugins/ca.py new file mode 100644 index 0000000000..51302d46eb --- /dev/null +++ b/ipadiag/reporters/plugins/ca.py @@ -0,0 +1,32 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.pluggable import Reporter, register +from ipapython.dn import DN +from ipalib import errors +from ipadiag.reporters.plugins.base import service_enabled + +@register() +class ca_enabled(service_enabled): + """ + Reports the enablement of the CA service. + """ + + group = 'CA' + service_name = 'CA' diff --git a/ipadiag/reporters/plugins/config.py b/ipadiag/reporters/plugins/config.py new file mode 100644 index 0000000000..990b6081c6 --- /dev/null +++ b/ipadiag/reporters/plugins/config.py @@ -0,0 +1,67 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.pluggable import Reporter, register +from ipapython.dn import DN + +class config_entry_reporter(Reporter): + """ + Reports the machine's hostname + """ + + group = 'Config' + server_only = True + attribute = None + + def get_config_value(self): + config_dn = DN(('cn', 'ipaConfig'), ('cn', 'etc'), self.api.env.basedn) + config = self.ldap.get_entry(config_dn, attrs_list=[self.attribute]) + + return config[self.attribute] + + +@register() +class migration_enabled(config_entry_reporter): + """ + Reports on migration mode status. + """ + + attribute = 'ipaMigrationEnabled' + cli_name = "Migration mode enabled" + + def report(self): + enabled = self.get_config_value()[0] + + if enabled == 'FALSE': + return False + elif enabled == 'TRUE': + return True + + +@register() +class default_selinuxusermap(config_entry_reporter): + """ + Reports the default SELinux user map. + """ + + attribute = 'ipaSELinuxUserMapDefault' + cli_name = "Default SELinux user map" + + def report(self): + return self.get_config_value()[0] diff --git a/ipadiag/reporters/plugins/dns.py b/ipadiag/reporters/plugins/dns.py new file mode 100644 index 0000000000..27e1faab68 --- /dev/null +++ b/ipadiag/reporters/plugins/dns.py @@ -0,0 +1,32 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.pluggable import Reporter, register +from ipapython.dn import DN +from ipalib import errors +from ipadiag.reporters.plugins.base import service_enabled + +@register() +class dns_enabled(service_enabled): + """ + Reports the enablement of the DNS service. + """ + + group = 'DNS' + service_name = 'DNS' diff --git a/ipadiag/reporters/plugins/domain.py b/ipadiag/reporters/plugins/domain.py new file mode 100644 index 0000000000..25b6002bfb --- /dev/null +++ b/ipadiag/reporters/plugins/domain.py @@ -0,0 +1,32 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.pluggable import DomainReporter, register + +@register() +class reached_masters(DomainReporter): + """ + Reports the machine's local time in UTC. + """ + + group = 'General' + cli_name = 'Number of masters reached' + + def report(self, host_reports): + return len(host_reports.keys()) diff --git a/ipadiag/reporters/plugins/hostname.py b/ipadiag/reporters/plugins/hostname.py new file mode 100644 index 0000000000..fac1e55a7d --- /dev/null +++ b/ipadiag/reporters/plugins/hostname.py @@ -0,0 +1,34 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.pluggable import Reporter, register + +import socket + +@register() +class hostname_reporter(Reporter): + """ + Reports the machine's hostname using socket.gethostname() call. + """ + + group = 'System' + cli_name = 'Hostname' + + def report(self): + return socket.gethostname() diff --git a/ipadiag/reporters/plugins/ldap_plugins.py b/ipadiag/reporters/plugins/ldap_plugins.py new file mode 100644 index 0000000000..609de560e5 --- /dev/null +++ b/ipadiag/reporters/plugins/ldap_plugins.py @@ -0,0 +1,182 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipapython.dn import DN +from ipadiag.pluggable import Reporter, register + +KNOWN_PLUGINS = set([ + u'7-bit check', + u'Account Policy Plugin', + u'Account Usability Plugin', + u'ACL Plugin', + u'ACL preoperation', + u'attribute uniqueness', + u'Auto Membership Plugin', + u'Binary Syntax', + u'Bit String', + u'Bitwise Plugin', + u'Boolean Syntax', + u'Case Exact String Syntax', + u'Case Ignore String Syntax', + u'certificate store issuer/serial uniqueness', + u'certificate store subject uniqueness', + u'chaining database', + u'Class of Service', + u'Content Synchronization', + u'Country String Syntax', + u'Delivery Method Syntax', + u'deref', + u'Distinguished Name Syntax', + u'Distributed Numeric Assignment Plugin', + u'Enhanced Guide Syntax', + u'Facsimile Telephone Number Syntax', + u'Fax Syntax', + u'Generalized Time Syntax', + u'Guide Syntax', + u'HTTP Client', + u'Integer Syntax', + u'Internationalization Plugin', + u'IPA DNS', + u'IPA Lockout', + u'IPA MODRDN', + u'IPA OTP Counter', + u'IPA OTP Last Token', + u'IPA Range-Check', + u'IPA UUID', + u'IPA Version Replication', + u'ipa-winsync', + u'ipa_enrollment_extop', + u'ipa_pwd_extop', + u'ipaUniqueID uniqueness', + u'JPEG Syntax', + u'krbCanonicalName uniqueness', + u'krbPrincipalName uniqueness', + u'ldbm database', + u'Legacy Replication Plugin', + u'Linked Attributes', + u'Managed Entries', + u'MemberOf Plugin', + u'Multimaster Replication Plugin', + u'Name And Optional UID Syntax', + u'netgroup uniqueness', + u'Numeric String Syntax', + u'Octet String Syntax', + u'OID Syntax', + u'PAM Pass Through Auth', + u'Pass Through Authentication', + u'Posix Winsync API', + u'Postal Address Syntax', + u'Printable String Syntax', + u'referential integrity postoperation', + u'Retro Changelog Plugin', + u'Roles Plugin', + u'RootDN Access Control', + u'Schema Compatibility', + u'Schema Reload', + u'Space Insensitive String Syntax', + u'State Change Plugin', + u'sudorule name uniqueness', + u'Syntax Validation Task', + u'Telephone Syntax', + u'Teletex Terminal Identifier Syntax', + u'Telex Number Syntax', + u'uid uniqueness', + u'URI Syntax', + u'USN', + u'Views', + u'whoami' +]) + + +KNOWN_DISABLED_PLUGINS = set([ + u'Account Policy Plugin', + u'attribute uniqueness', + u'PAM Pass Through Auth', + u'Pass Through Authentication', + u'Posix Winsync API', + u'RootDN Access Control', + u'Space Insensitive String Syntax', + u'URI Syntax' +]) + + +class ldap_plugins_base(Reporter): + """ + Base class for plugins related to LDAP plugins. + """ + + group = 'LDAP' + requires_root = True + server_only = True + + def get_enabled_plugins(self): + plugins_base = DN(('cn','plugins'), ('cn','config')) + + enabled_plugins_filter = self.ldap.make_filter_from_attr( + 'nsslapd-pluginEnabled', 'on', + ) + + result, _ = self.ldap.find_entries( + enabled_plugins_filter, + attrs_list=['cn'], + base_dn=plugins_base, + scope=self.ldap.SCOPE_ONELEVEL + ) + + return set([entry['cn'][0] for entry in result]) + + +@register() +class ldap_plugins_count(ldap_plugins_base): + """ + Returns the total number of allowed LDAP plugins. + """ + + cli_name = 'Number of enabled plugins' + + def report(self): + plugins = self.get_enabled_plugins() + return len(plugins) + + +@register() +class ldap_plugins_extra(ldap_plugins_base): + """ + Returns names of any non-default LDAP plugins. + """ + + cli_name = 'Extra plugins' + + def report(self): + plugins = self.get_enabled_plugins() + return ', '.join(plugins - KNOWN_PLUGINS) + + +@register() +class ldap_plugins_missing(ldap_plugins_base): + """ + Returns names of any missing LDAP plugins. + """ + + cli_name = 'Missing plugins' + + def report(self): + plugins = self.get_enabled_plugins() + return ', '.join(KNOWN_PLUGINS - KNOWN_DISABLED_PLUGINS - plugins) + diff --git a/ipadiag/reporters/plugins/ldap_process_size.py b/ipadiag/reporters/plugins/ldap_process_size.py new file mode 100644 index 0000000000..06b65a2b9a --- /dev/null +++ b/ipadiag/reporters/plugins/ldap_process_size.py @@ -0,0 +1,54 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipalib import api +from ipadiag.pluggable import Reporter, register + +import socket +import resource + +@register() +class disrv_process_size(Reporter): + """ + Reports the machine's hostname + """ + + group = 'LDAP' + cli_name = 'Process size' + server_only = True + + def get_dirsrv_pid(self): + result = self.run(['pgrep', 'ns-slapd']) + return result.stdout.strip() + + def get_process_size(self, pid): + page_size = resource.getpagesize() + + with open("/proc/%s/statm" % pid, "r") as stats: + data = stats.read().strip().split() + + pages_used = int(data[0]) + process_size = pages_used * page_size + + return process_size + + def report(self): + pid = self.get_dirsrv_pid() + bytes_used = self.get_process_size(pid) + return "%s kB" % (bytes_used / 1024) diff --git a/ipadiag/reporters/plugins/replication.py b/ipadiag/reporters/plugins/replication.py new file mode 100644 index 0000000000..3e81124328 --- /dev/null +++ b/ipadiag/reporters/plugins/replication.py @@ -0,0 +1,83 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipapython.dn import DN +from ipadiag.pluggable import Reporter, register + + +class base_replica_reporter(Reporter): + """ + Base class for replica replated reporters. + """ + + group = 'LDAP' + server_only = True + + def get_replica_names(self): + agreements_base = DN( + ('cn','replica'), + ('cn', self.api.env.basedn), + ('cn','mapping tree'), + ('cn','config') + ) + + agreements_filter = self.ldap.make_filter_from_attr( + 'cn', 'meTo', + exact=False, + leading_wildcard=False + ) + + result, _ = self.ldap.find_entries( + agreements_filter, + attrs_list=['cn'], + base_dn=agreements_base, + scope=self.ldap.SCOPE_ONELEVEL + ) + + # Return the list of replicas, by stripping the + # meTo prefix + return [entry['cn'][0][4:] for entry in result] + + +@register() +class replication_agreements_list(base_replica_reporter): + """ + Reports the number of replication agreements of this particular master. + """ + + group = 'LDAP' + cli_name = 'List of current hosts replicas' + server_only = True + + def report(self): + return self.get_replica_names() + + +@register() +class replication_agreements_count(base_replica_reporter): + """ + Reports the number of replication agreements of this particular master. + """ + + group = 'LDAP' + cli_name = 'Number of replication agreements' + server_only = True + + def report(self): + return len(self.get_replica_names()) diff --git a/ipadiag/reporters/plugins/time.py b/ipadiag/reporters/plugins/time.py new file mode 100644 index 0000000000..2276135882 --- /dev/null +++ b/ipadiag/reporters/plugins/time.py @@ -0,0 +1,34 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from datetime import datetime +from ipadiag.pluggable import Reporter, register + +@register() +class time_reporter(Reporter): + """ + Reports the machine's local time in UTC. + """ + + group = 'System' + cli_name = 'Time in UTC' + + def report(self): + time = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + return time diff --git a/ipadiag/reporters/plugins/trusts.py b/ipadiag/reporters/plugins/trusts.py new file mode 100644 index 0000000000..3e67cc9ddf --- /dev/null +++ b/ipadiag/reporters/plugins/trusts.py @@ -0,0 +1,32 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 . +# + +from ipadiag.reporters.plugins.base import service_enabled +from ipadiag.pluggable import Reporter, register +from ipapython.dn import DN +from ipalib import errors + +@register() +class trusts_enabled(service_enabled): + """ + Reports the machine's hostname + """ + + group = 'Trusts' + service_name = 'ADTRUST' diff --git a/ipadiag/result.py b/ipadiag/result.py new file mode 100644 index 0000000000..78144e28a4 --- /dev/null +++ b/ipadiag/result.py @@ -0,0 +1,146 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 abc +import json +import collections + +from ipadiag.errors import DiagException + +class abstractclassmethod(classmethod): + + __isabstractmethod__ = True + + def __init__(self, method): + method.__isabstractmethod__ = True + super(abstractclassmethod, self).__init__(method) + + +class DiagPluginResult(object): + + __metaclasses__ = abc.ABCMeta + + def __init__(self, status, value, error_key=None): + if not isinstance(status, bool): + raise ValueError("Result status must be either True or False") + + self.status = status + self.value = value + self.error_key = error_key + + @abstractclassmethod + def from_diagnose_exception(cls, exception): + pass + + @abc.abstractmethod + def to_dict(self): + pass + + @abstractclassmethod + def from_dict(cls, data): + pass + + +class ReporterResult(DiagPluginResult): + + @classmethod + def from_diagnose_exception(cls, exception): + return cls(False, exception.message, exception.key) + + def to_dict(self): + data = { + 'status': self.status, + 'value': self.value, + } + + if self.error_key: + data['error_key'] = self.error_key + + return data + + @classmethod + def from_dict(cls, data): + return cls( + status=data['status'], + value=data['value'], + error_key=data.get('error_key') + ) + + +class DoctorResult(DiagPluginResult): + + def __init__(self, status, value, error_key=None, advice=None): + """ + Initializes the Doctor result instance. + """ + + super(DoctorResult, self).__init__(status, value, error_key) + self.advice = advice + + @classmethod + def from_diagnose_exception(cls, exception, advice=None): + """ + Constructs the Doctor result instance from DiagException object. + """ + + return cls(False, exception.message, exception.key, advice) + + def to_dict(self): + """ + Converts the DoctorResult to a dict representation. + """ + + data = { + 'status': self.status, + 'value': self.value, + } + + if self.error_key: + data['error_key'] = self.error_key + + if self.advice: + data['advice'] = self.advice + + return data + + @classmethod + def from_dict(cls, data): + """ + Constructs the DoctorResult from a dict representation + """ + + return cls( + status=data['status'], + value=data['value'], + error_key=data.get('error_key'), + advice=data.get('advice') + ) + +# Define named tuples for the result of the diagnostics + +HostDiagnosticsResult = collections.namedtuple( + 'HostDiagnosticsResult', + ['reports', 'checks'] +) + + +DomainDiagnosticsResult = collections.namedtuple( + 'DomainDiagnosticsResult', + ['reports', 'checks', 'unreachable'] +) diff --git a/ipadiag/util.py b/ipadiag/util.py new file mode 100644 index 0000000000..5a3e2ecd79 --- /dev/null +++ b/ipadiag/util.py @@ -0,0 +1,127 @@ +# Authors: Tomas Babej +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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 collections +import tempfile +import distutils.spawn + +from ipapython.dn import DN +from ipapython.ipautil import run + + +# Namedtuple to wrap the result of executed commands +CommandResult = collections.namedtuple( + 'CommandResult', + ['stdout', 'stderr', 'returncode'] +) + + +class abstractclassmethod(classmethod): + """ + A backport of abstractclassmethod decorator from Python 3.3. + """ + + __isabstractmethod__ = True + + def __init__(self, method): + method.__isabstractmethod__ = True + super(abstractclassmethod, self).__init__(method) + + +def get_hosts_fqdn(api, ldap): + """ + Returns list of fully qualified domain names of all the hosts in the domain. + """ + + hosts_dn = DN(api.env.container_host, api.env.basedn) + + # Perform the search, asking only for the fqdn attribute + result, _ = ldap.find_entries( + attrs_list=['fqdn'], + base_dn=hosts_dn, + scope=ldap.SCOPE_ONELEVEL + ) + + hosts_fqdns = set([entry['fqdn'][0] for entry in result]) + + return hosts_fqdns + + +def get_masters_fqdn(api, ldap): + """ + Returns list of fully qualified domain names of all the masters in the domain. + """ + + masters_dn = DN( + ('cn', 'masters'), + ('cn', 'ipa'), + ('cn', 'etc'), + api.env.basedn + ) + + # Perform the search, asking only for the cn attribute + result, _ = ldap.find_entries( + attrs_list=['cn'], + base_dn=masters_dn, + scope=ldap.SCOPE_ONELEVEL + ) + + fqdns = set([entry['cn'][0] for entry in result]) + + return fqdns + + +def get_clients_fqdn(api, ldap): + """ + Returns list of fully qualified domain names of all the clients in the domain. + """ + + # Clients can be computed as set difference of hosts - masters + master_fqdns = get_masters_fqdn(api, ldap) + hosts_fqdns = get_hosts_fqdn(api, ldap) + + return hosts_fqdns - master_fqdns + + +def execute_remote(host, args): + """ + Execute a command on the remote host. + """ + + # Locate the ssh executable + ssh_path = distutils.spawn.find_executable('ssh') + command = ' '.join(args) + + if ssh_path is None: + return None + + # Generate temporary file for UserKnownHostsFile + tmpf = tempfile.NamedTemporaryFile() + local_cmd = [ + ssh_path, + '-K', + '-o BatchMode=yes', + '-o StrictHostKeychecking=no', + '-o GSSAPIKeyExchange=yes', + '-o UserKnownHostsFile=%s' % tmpf.name, + host, command + ] + + remote_result = run(local_cmd, raiseonerr=False) + return CommandResult(*remote_result) diff --git a/ipalib/cli.py b/ipalib/cli.py index b0e1389251..3db436223b 100644 --- a/ipalib/cli.py +++ b/ipalib/cli.py @@ -674,6 +674,31 @@ def select_entry(self, entries, format, attrs, display_count=True): self.print_line('') return selection + def display_progressbar(self, current, maximum, prefix="", size=None): + + if size is None: + max_width = self.get_tty_width() + reserved_space = len(prefix) + 5 + 2 * len(str(maximum)) + size = max_width - reserved_space + + progress = float(current) / maximum if maximum > 0 else 1 + filled_tiles = int(size * progress) + empty_tiles = size - filled_tiles + + template = "{0} [{1}{2}] {3}/{4}" + output = template.format( + prefix, + filled_tiles * '#', + empty_tiles * '.', + current, + maximum + ) + + sys.stdout.write(output) + sys.stdout.write('\n' if current == maximum else '\r') + sys.stdout.flush() + + class help(frontend.Local): """ Display help for a command or topic. diff --git a/ipaplatform/base/paths.py b/ipaplatform/base/paths.py index 97c330c318..a3e221bb20 100644 --- a/ipaplatform/base/paths.py +++ b/ipaplatform/base/paths.py @@ -161,6 +161,7 @@ class BasePathNamespace(object): PKCS12EXPORT = "/usr/bin/PKCS12Export" CERTUTIL = "/usr/bin/certutil" CHROMIUM_BROWSER = "/usr/bin/chromium-browser" + DIG = "/usr/bin/dig" DS_NEWINST_PL = "/usr/bin/ds_newinst.pl" FIREFOX = "/usr/bin/firefox" GETCERT = "/usr/bin/getcert" diff --git a/make-lint b/make-lint index 2cffe09382..986eae69fe 100755 --- a/make-lint +++ b/make-lint @@ -45,7 +45,7 @@ IGNORE_PATHS = ( class IPATypeChecker(TypeChecker): NAMESPACE_ATTRS = ['Command', 'Object', 'Method', 'Backend', 'Updater', - 'Advice'] + 'Advice', 'Reporter', 'Doctor', 'DomainReporter', 'DomainDoctor'] LOGGING_ATTRS = ['log', 'debug', 'info', 'warning', 'error', 'exception', 'critical'] diff --git a/setup.py b/setup.py index 434aeb8cc4..7955d3621d 100755 --- a/setup.py +++ b/setup.py @@ -76,6 +76,11 @@ def run(self): license='GPLv3+', url='http://freeipa.org/', packages=[ + 'ipadiag', + 'ipadiag.doctors', + 'ipadiag.doctors.plugins', + 'ipadiag.reporters', + 'ipadiag.reporters.plugins', 'ipalib', 'ipalib.plugins', 'ipaserver',