diff options
author | Rob Crittenden <rcritten@redhat.com> | 2013-03-13 09:36:41 -0400 |
---|---|---|
committer | Rob Crittenden <rcritten@redhat.com> | 2013-04-12 09:59:17 -0400 |
commit | c8694cb19f2b0bd20a0b3fc9df7aacec3b23a928 (patch) | |
tree | c13c5965a41c328e9cae04530e550522ae49a678 /ipaserver/install | |
parent | c0cdba78b01317a9ea5c423eda548a69ee046e26 (diff) | |
download | freeipa-c8694cb19f2b0bd20a0b3fc9df7aacec3b23a928.tar.gz freeipa-c8694cb19f2b0bd20a0b3fc9df7aacec3b23a928.tar.xz freeipa-c8694cb19f2b0bd20a0b3fc9df7aacec3b23a928.zip |
Full system backup and restore
This will allow one to backup and restore the IPA files and data. This
does not cover individual entry restoration.
http://freeipa.org/page/V3/Backup_and_Restore
https://fedorahosted.org/freeipa/ticket/3128
Diffstat (limited to 'ipaserver/install')
-rw-r--r-- | ipaserver/install/ipa_backup.py | 568 | ||||
-rw-r--r-- | ipaserver/install/ipa_restore.py | 634 | ||||
-rw-r--r-- | ipaserver/install/replication.py | 163 |
3 files changed, 1355 insertions, 10 deletions
diff --git a/ipaserver/install/ipa_backup.py b/ipaserver/install/ipa_backup.py new file mode 100644 index 000000000..54694c99d --- /dev/null +++ b/ipaserver/install/ipa_backup.py @@ -0,0 +1,568 @@ +#!/usr/bin/python +# Authors: Rob Crittenden <rcritten@redhat.com +# +# 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 <http://www.gnu.org/licenses/>. +# + +import os +import sys +import shutil +import tempfile +import time +import pwd +from optparse import OptionGroup +from ConfigParser import SafeConfigParser + +from ipalib import api, errors +from ipapython import version +from ipapython.ipautil import run, write_tmp_file +from ipapython import admintool +from ipapython.config import IPAOptionParser +from ipapython.dn import DN +from ipaserver.install.dsinstance import realm_to_serverid, DS_USER +from ipaserver.install.replication import wait_for_task +from ipaserver.install import installutils +from ipapython import services as ipaservices +from ipapython import ipaldap +from ipalib.session import ISO8601_DATETIME_FMT +from ConfigParser import SafeConfigParser + +""" +A test gpg can be generated list this: + +# cat >keygen <<EOF + %echo Generating a standard key + Key-Type: RSA + Key-Length: 2048 + Name-Real: IPA Backup + Name-Comment: IPA Backup + Name-Email: root@example.com + Expire-Date: 0 + %pubring /root/backup.pub + %secring /root/backup.sec + %commit + %echo done +EOF +# gpg --batch --gen-key keygen +# gpg --no-default-keyring --secret-keyring /root/backup.sec \ + --keyring /root/backup.pub --list-secret-keys +""" + +BACKUP_DIR = '/var/lib/ipa/backup' + + +def encrypt_file(filename, keyring, remove_original=True): + source = filename + dest = filename + '.gpg' + + args = ['/usr/bin/gpg', + '--batch', + '--default-recipient-self', + '-o', dest] + + if keyring is not None: + args.append('--no-default-keyring') + args.append('--keyring') + args.append(keyring + '.pub') + args.append('--secret-keyring') + args.append(keyring + '.sec') + + args.append('-e') + args.append(source) + + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + raise admintool.ScriptError('gpg failed: %s' % stderr) + + if remove_original: + os.unlink(source) + + return dest + + +class Backup(admintool.AdminTool): + command_name = 'ipa-backup' + log_file_name = '/var/log/ipabackup.log' + + usage = "%prog [options]" + + description = "Back up IPA files and databases." + + dirs = ('/usr/share/ipa/html', + '/root/.pki', + '/etc/pki-ca', + '/etc/pki/pki-tomcat', + '/etc/sysconfig/pki', + '/etc/httpd/alias', + '/var/lib/pki', + '/var/lib/pki-ca', + '/var/lib/ipa/sysrestore', + '/var/lib/ipa-client/sysrestore', + '/var/lib/sss/pubconf/krb5.include.d', + '/var/lib/authconfig/last', + '/var/lib/certmonger', + '/var/lib/ipa', + '/var/run/dirsrv', + '/var/lock/dirsrv', + ) + + files = ( + '/etc/named.conf', + '/etc/named.keytab', + '/etc/resolv.conf', + '/etc/sysconfig/pki-ca', + '/etc/sysconfig/pki-tomcat', + '/etc/sysconfig/dirsrv', + '/etc/sysconfig/ntpd', + '/etc/sysconfig/krb5kdc', + '/etc/sysconfig/pki/ca/pki-ca', + '/etc/sysconfig/authconfig', + '/etc/pki/nssdb/cert8.db', + '/etc/pki/nssdb/key3.db', + '/etc/pki/nssdb/secmod.db', + '/etc/nsswitch.conf', + '/etc/krb5.keytab', + '/etc/sssd/sssd.conf', + '/etc/openldap/ldap.conf', + '/etc/security/limits.conf', + '/etc/httpd/conf/password.conf', + '/etc/httpd/conf/ipa.keytab', + '/etc/httpd/conf.d/ipa-pki-proxy.conf', + '/etc/httpd/conf.d/ipa-rewrite.conf', + '/etc/httpd/conf.d/nss.conf', + '/etc/httpd/conf.d/ipa.conf', + '/etc/ssh/sshd_config', + '/etc/ssh/ssh_config', + '/etc/krb5.conf', + '/etc/group', + '/etc/passwd', + '/etc/ipa/ca.crt', + '/etc/ipa/default.conf', + '/etc/dirsrv/ds.keytab', + '/etc/ntp.conf', + '/etc/samba/smb.conf', + '/etc/samba/samba.keytab', + '/root/ca-agent.p12', + '/root/cacert.p12', + '/var/kerberos/krb5kdc/kdc.conf', + '/etc/systemd/system/multi-user.target.wants/ipa.service', + '/etc/systemd/system/multi-user.target.wants/sssd.service', + '/etc/systemd/system/multi-user.target.wants/certmonger.service', + '/etc/systemd/system/pki-tomcatd.target.wants/pki-tomcatd@pki-tomcat.service', + '/var/run/ipa/services.list', + ) + + logs=( + '/var/log/pki-ca', + '/var/log/pki/', + '/var/log/dirsrv/slapd-PKI-IPA', + '/var/log/httpd', + '/var/log/ipaserver-install.log', + '/var/log/kadmind.log', + '/var/log/pki-ca-install.log', + '/var/log/messages', + '/var/log/ipaclient-install.log', + '/var/log/secure', + '/var/log/ipaserver-uninstall.log', + '/var/log/pki-ca-uninstall.log', + '/var/log/ipaclient-uninstall.log', + '/var/named/data/named.run', + ) + + def __init__(self, options, args): + super(Backup, self).__init__(options, args) + self._conn = None + self.files = list(self.files) + self.dirs = list(self.dirs) + self.logs = list(self.logs) + + @classmethod + def add_options(cls, parser): + super(Backup, cls).add_options(parser, debug_option=True) + + parser.add_option("--gpg-keyring", dest="gpg_keyring", + help="The gpg key name to be used (or full path)") + parser.add_option("--gpg", dest="gpg", action="store_true", + default=False, help="Encrypt the backup") + parser.add_option("--data", dest="data_only", action="store_true", + default=False, help="Backup only the data") + parser.add_option("--logs", dest="logs", action="store_true", + default=False, help="Include log files in backup") + parser.add_option("--online", dest="online", action="store_true", + default=False, help="Perform the LDAP backups online, for data only.") + + + def setup_logging(self, log_file_mode='a'): + super(Backup, self).setup_logging(log_file_mode='a') + + + def validate_options(self): + options = self.options + super(Backup, self).validate_options(needs_root=True) + installutils.check_server_configuration() + + if options.gpg_keyring is not None: + if not os.path.exists(options.gpg_keyring + '.pub'): + raise admintool.ScriptError('No such key %s' % + options.gpg_keyring) + options.gpg = True + + if options.online and not options.data_only: + self.option_parser.error("You cannot specify --online " + "without --data") + + if options.gpg: + tmpfd = write_tmp_file('encryptme') + newfile = encrypt_file(tmpfd.name, options.gpg_keyring, False) + os.unlink(newfile) + + if options.data_only and options.logs: + self.option_parser.error("You cannot specify --data " + "with --logs") + + + def run(self): + options = self.options + super(Backup, self).run() + + api.bootstrap(in_server=False, context='backup') + api.finalize() + + self.log.info("Preparing backup on %s", api.env.host) + + pent = pwd.getpwnam(DS_USER) + + self.top_dir = tempfile.mkdtemp("ipa") + os.chown(self.top_dir, pent.pw_uid, pent.pw_gid) + os.chmod(self.top_dir, 0750) + self.dir = os.path.join(self.top_dir, "ipa") + os.mkdir(self.dir, 0750) + + os.chown(self.dir, pent.pw_uid, pent.pw_gid) + + self.header = os.path.join(self.top_dir, 'header') + + cwd = os.getcwd() + try: + dirsrv = ipaservices.knownservices.dirsrv + + self.add_instance_specific_data() + + # We need the dirsrv running to get the list of services + dirsrv.start(capture_output=False) + + self.get_connection() + + self.create_header(options.data_only) + if options.data_only: + if not options.online: + self.log.info('Stopping Directory Server') + dirsrv.stop(capture_output=False) + else: + self.log.info('Stopping IPA services') + run(['ipactl', 'stop']) + + for instance in [realm_to_serverid(api.env.realm), 'PKI-IPA']: + if os.path.exists('/var/lib/dirsrv/slapd-%s' % instance): + if os.path.exists('/var/lib/dirsrv/slapd-%s/db/ipaca' % instance): + self.db2ldif(instance, 'ipaca', online=options.online) + self.db2ldif(instance, 'userRoot', online=options.online) + self.db2bak(instance, online=options.online) + if not options.data_only: + self.file_backup(options) + self.finalize_backup(options.data_only, options.gpg, options.gpg_keyring) + + if options.data_only: + if not options.online: + self.log.info('Starting Directory Server') + dirsrv.start(capture_output=False) + else: + self.log.info('Starting IPA service') + run(['ipactl', 'start']) + + finally: + try: + os.chdir(cwd) + except Exception, e: + self.log.error('Cannot change directory to %s: %s' % (cwd, e)) + shutil.rmtree(self.top_dir) + + + def add_instance_specific_data(self): + ''' + Add instance-specific files and directories. + + NOTE: this adds some things that may not get backed up, like the PKI-IPA + instance. + ''' + for dir in [ + '/etc/dirsrv/slapd-%s' % realm_to_serverid(api.env.realm), + '/var/lib/dirsrv/scripts-%s' % realm_to_serverid(api.env.realm), + '/var/lib/dirsrv/slapd-%s' % realm_to_serverid(api.env.realm), + '/usr/lib64/dirsrv/slapd-PKI-IPA', + '/usr/lib/dirsrv/slapd-PKI-IPA', + '/etc/dirsrv/slapd-PKI-IPA', + '/var/lib/dirsrv/slapd-PKI-IPA', + self.__find_scripts_dir('PKI-IPA'), + ]: + if os.path.exists(dir): + self.dirs.append(dir) + + for file in [ + '/etc/sysconfig/dirsrv-%s' % realm_to_serverid(api.env.realm), + '/etc/sysconfig/dirsrv-PKI-IPA']: + if os.path.exists(file): + self.files.append(file) + + for log in [ + '/var/log/dirsrv/slapd-%s' % realm_to_serverid(api.env.realm),]: + self.logs.append(log) + + + def get_connection(self): + ''' + Create an ldapi connection and bind to it using autobind as root. + ''' + if self._conn is not None: + return self._conn + + self._conn = ipaldap.IPAdmin(host=api.env.host, + ldapi=True, + protocol='ldapi', + realm=api.env.realm) + + try: + pw_name = pwd.getpwuid(os.geteuid()).pw_name + self._conn.do_external_bind(pw_name) + except Exception, e: + self.log.error("Unable to bind to LDAP server %s: %s" % + (self._conn.host, e)) + + return self._conn + + + def db2ldif(self, instance, backend, online=True): + ''' + Create a LDIF backup of the data in this instance. + + If executed online create a task and wait for it to complete. + + For SELinux reasons this writes out to the 389-ds backup location + and we move it. + ''' + self.log.info('Backing up %s in %s to LDIF' % (backend, instance)) + + now = time.localtime() + cn = time.strftime('export_%Y_%m_%d_%H_%M_%S') + dn = DN(('cn', cn), ('cn', 'export'), ('cn', 'tasks'), ('cn', 'config')) + + ldifname = '%s-%s.ldif' % (instance, backend) + ldiffile = os.path.join( + '/var/lib/dirsrv/slapd-%s/ldif' % instance, + ldifname) + + if online: + conn = self.get_connection() + ent = conn.make_entry( + dn, + { + 'objectClass': ['top', 'extensibleObject'], + 'cn': [cn], + 'nsInstance': [backend], + 'nsFilename': [ldiffile], + 'nsUseOneFile': ['true'], + 'nsExportReplica': ['true'], + } + ) + + try: + conn.add_entry(ent) + except Exception, e: + raise admintool.ScriptError('Unable to add LDIF task: %s' + % e) + + self.log.info("Waiting for LDIF to finish") + wait_for_task(conn, dn) + else: + args = ['%s/db2ldif' % self.__find_scripts_dir(instance), + '-r', + '-n', backend, + '-a', ldiffile] + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + self.log.critical("db2ldif failed: %s", stderr) + + # Move the LDIF backup to our location + shutil.move(ldiffile, os.path.join(self.dir, ldifname)) + + + def db2bak(self, instance, online=True): + ''' + Create a BAK backup of the data and changelog in this instance. + + If executed online create a task and wait for it to complete. + ''' + self.log.info('Backing up %s' % instance) + now = time.localtime() + cn = time.strftime('backup_%Y_%m_%d_%H_%M_%S') + dn = DN(('cn', cn), ('cn', 'backup'), ('cn', 'tasks'), ('cn', 'config')) + + bakdir = os.path.join('/var/lib/dirsrv/slapd-%s/bak/%s' % (instance, instance)) + + if online: + conn = self.get_connection() + ent = conn.make_entry( + dn, + { + 'objectClass': ['top', 'extensibleObject'], + 'cn': [cn], + 'nsInstance': ['userRoot'], + 'nsArchiveDir': [bakdir], + 'nsDatabaseType': ['ldbm database'], + } + ) + + try: + conn.add_entry(ent) + except Exception, e: + raise admintool.ScriptError('Unable to to add backup task: %s' + % e) + + self.log.info("Waiting for BAK to finish") + wait_for_task(conn, dn) + else: + args = ['%s/db2bak' % self.__find_scripts_dir(instance), bakdir] + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + self.log.critical("db2bak failed: %s" % stderr) + + shutil.move(bakdir, self.dir) + + + def file_backup(self, options): + + def verify_directories(dirs): + return [s for s in dirs if os.path.exists(s)] + + self.log.info("Backing up files") + args = ['tar', + '--xattrs', + '--selinux', + '-czf', + os.path.join(self.dir, 'files.tar') + ] + + args.extend(verify_directories(self.dirs)) + args.extend(verify_directories(self.files)) + + if options.logs: + args.extend(verify_directories(self.logs)) + + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + raise admintool.ScriptError('tar returned non-zero %d: %s' % (rc, stdout)) + + + def create_header(self, data_only): + ''' + Create the backup file header that contains the meta data about + this particular backup. + ''' + config = SafeConfigParser() + config.add_section("ipa") + if data_only: + config.set('ipa', 'type', 'DATA') + else: + config.set('ipa', 'type', 'FULL') + config.set('ipa', 'time', time.strftime(ISO8601_DATETIME_FMT, time.gmtime())) + config.set('ipa', 'host', api.env.host) + config.set('ipa', 'ipa_version', str(version.VERSION)) + config.set('ipa', 'version', '1') + + dn = DN(('cn', api.env.host), ('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn) + services_cns = [] + try: + conn = self.get_connection() + services = conn.get_entries(dn, conn.SCOPE_ONELEVEL) + except errors.NetworkError: + self.log.critical( + "Unable to obtain list of master services, continuing anyway") + except Exception, e: + self.log.error("Failed to read services from '%s': %s" % + (conn.host, e)) + else: + services_cns = [s.single_value('cn') for s in services] + + config.set('ipa', 'services', ','.join(services_cns)) + with open(self.header, 'w') as fd: + config.write(fd) + + + def finalize_backup(self, data_only=False, encrypt=False, keyring=None): + ''' + Create the final location of the backup files and move the files + we've backed up there, optionally encrypting them. + + This is done in a couple of steps. We have a directory that + contains the tarball of the files, a directory that contains + the db2bak output and an LDIF. + + These, along with the header, are moved into a new subdirectory + in /var/lib/ipa/backup. + ''' + + if data_only: + backup_dir = os.path.join(BACKUP_DIR, time.strftime('ipa-data-%Y-%m-%d-%H-%M-%S')) + filename = os.path.join(backup_dir, "ipa-data.tar") + else: + backup_dir = os.path.join(BACKUP_DIR, time.strftime('ipa-full-%Y-%m-%d-%H-%M-%S')) + filename = os.path.join(backup_dir, "ipa-full.tar") + + os.mkdir(backup_dir, 0700) + + cwd = os.getcwd() + os.chdir(self.dir) + args = ['tar', + '--xattrs', + '--selinux', + '-czf', + filename, + '.' + ] + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + raise admintool.ScriptError('tar returned non-zero %d: %s' % (rc, stdout)) + + if encrypt: + self.log.info('Encrypting %s' % filename) + filename = encrypt_file(filename, keyring) + + shutil.move(self.header, backup_dir) + + def __find_scripts_dir(self, instance): + """ + IPA stores its 389-ds scripts in a different directory than dogtag + does so we need to probe for it. + """ + if instance != 'PKI-IPA': + return os.path.join('/var/lib/dirsrv', 'scripts-%s' % instance) + else: + if sys.maxsize > 2**32: + libpath = 'lib64' + else: + libpath = 'lib' + return os.path.join('/usr', libpath, 'dirsrv', 'slapd-PKI-IPA') diff --git a/ipaserver/install/ipa_restore.py b/ipaserver/install/ipa_restore.py new file mode 100644 index 000000000..04d42100c --- /dev/null +++ b/ipaserver/install/ipa_restore.py @@ -0,0 +1,634 @@ +#!/usr/bin/python +# Authors: Rob Crittenden <rcritten@redhat.com +# +# 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 <http://www.gnu.org/licenses/>. +# + +import os +import sys +import shutil +import glob +import tempfile +import time +import pwd +from optparse import OptionGroup +from ConfigParser import SafeConfigParser + +from ipalib import api, errors +from ipapython import version +from ipapython.ipautil import run, user_input +from ipapython import admintool +from ipapython.config import IPAOptionParser +from ipapython.dn import DN +from ipaserver.install.dsinstance import realm_to_serverid, DS_USER +from ipaserver.install.cainstance import PKI_USER +from ipaserver.install.replication import (wait_for_task, ReplicationManager, + CSReplicationManager, get_cs_replication_manager) +from ipaserver.install import installutils +from ipapython import services as ipaservices +from ipapython import ipaldap +from ipapython import version +from ipalib.session import ISO8601_DATETIME_FMT +from ipaserver.install.ipa_backup import BACKUP_DIR + + +def recursive_chown(path, uid, gid): + ''' + Change ownership of all files and directories in a path. + ''' + for root, dirs, files in os.walk(path): + for dir in dirs: + os.chown(os.path.join(root, dir), uid, gid) + os.chmod(os.path.join(root, dir), 0750) + for file in files: + os.chown(os.path.join(root, file), uid, gid) + os.chmod(os.path.join(root, file), 0640) + + +def decrypt_file(tmpdir, filename, keyring): + source = filename + (dest, ext) = os.path.splitext(filename) + + if ext != '.gpg': + raise admintool.ScriptError('Trying to decrypt a non-gpg file') + + dest = os.path.basename(dest) + dest = os.path.join(tmpdir, dest) + + args = ['/usr/bin/gpg', + '--batch', + '-o', dest] + + if keyring is not None: + args.append('--no-default-keyring') + args.append('--keyring') + args.append(keyring + '.pub') + args.append('--secret-keyring') + args.append(keyring + '.sec') + + args.append('-d') + args.append(source) + + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + raise admintool.ScriptError('gpg failed: %s' % stderr) + + return dest + + +class Restore(admintool.AdminTool): + command_name = 'ipa-restore' + log_file_name = '/var/log/iparestore.log' + + usage = "%prog [options] backup" + + description = "Restore IPA files and databases." + + def __init__(self, options, args): + super(Restore, self).__init__(options, args) + self._conn = None + + @classmethod + def add_options(cls, parser): + super(Restore, cls).add_options(parser, debug_option=True) + + parser.add_option("-p", "--password", dest="password", + help="Directory Manager password") + parser.add_option("--gpg-keyring", dest="gpg_keyring", + help="The gpg key name to be used") + parser.add_option("--data", dest="data_only", action="store_true", + default=False, help="Restore only the data") + parser.add_option("--online", dest="online", action="store_true", + default=False, help="Perform the LDAP restores online, for data only.") + parser.add_option("--instance", dest="instance", + help="The 389-ds instance to restore (defaults to all found)") + parser.add_option("--backend", dest="backend", + help="The backend to restore within the instance or instances") + parser.add_option('--no-logs', dest="no_logs", action="store_true", + default=False, help="Do not restore log files from the backup") + parser.add_option('-U', '--unattended', dest="unattended", + action="store_true", default=False, + help="Unattended restoration never prompts the user") + + + def setup_logging(self, log_file_mode='a'): + super(Restore, self).setup_logging(log_file_mode='a') + + + def validate_options(self): + options = self.options + super(Restore, self).validate_options(needs_root=True) + if options.data_only: + installutils.check_server_configuration() + + if len(self.args) < 1: + self.option_parser.error( + "must provide the backup to restore") + elif len(self.args) > 1: + self.option_parser.error( + "must provide exactly one name for the backup") + + dirname = self.args[0] + if not os.path.isabs(dirname): + self.backup_dir = os.path.join(BACKUP_DIR, dirname) + else: + self.backup_dir = dirname + + if options.gpg_keyring: + if (not os.path.exists(options.gpg_keyring + '.pub') or + not os.path.exists(options.gpg_keyring + '.sec')): + raise admintool.ScriptError('No such key %s' % + options.gpg_keyring) + + + def ask_for_options(self): + options = self.options + super(Restore, self).ask_for_options() + + # get the directory manager password + self.dirman_password = options.password + if not options.password: + if not options.unattended: + self.dirman_password = installutils.read_password( + "Directory Manager (existing master)", + confirm=False, validate=False) + if self.dirman_password is None: + raise admintool.ScriptError( + "Directory Manager password required") + + + def run(self): + options = self.options + super(Restore, self).run() + + api.bootstrap(in_server=False, context='restore') + api.finalize() + + self.log.info("Preparing restore from %s on %s", + self.backup_dir, api.env.host) + + if not options.instance: + instances = [] + for instance in [realm_to_serverid(api.env.realm), 'PKI-IPA']: + if os.path.exists('/var/lib/dirsrv/slapd-%s' % instance): + instances.append(instance) + else: + instances = [options.instance] + if options.data_only and not instances: + raise admintool.ScriptError('No instances to restore to') + + pent = pwd.getpwnam(DS_USER) + + # Temporary directory for decrypting files before restoring + self.top_dir = tempfile.mkdtemp("ipa") + os.chown(self.top_dir, pent.pw_uid, pent.pw_gid) + os.chmod(self.top_dir, 0750) + self.dir = os.path.join(self.top_dir, "ipa") + os.mkdir(self.dir, 0750) + + os.chown(self.dir, pent.pw_uid, pent.pw_gid) + + self.header = os.path.join(self.backup_dir, 'header') + + cwd = os.getcwd() + try: + dirsrv = ipaservices.knownservices.dirsrv + + self.read_header() + # These two checks would normally be in the validate method but + # we need to know the type of backup we're dealing with. + if (self.backup_type != 'FULL' and not options.data_only and + not instances): + raise admintool.ScriptError('Cannot restore a data backup into an empty system') + if (self.backup_type == 'FULL' and not options.data_only and + (options.instance or options.backend)): + raise admintool.ScriptError('Restore must be in data-only mode when restoring a specific instance or backend.') + if self.backup_host != api.env.host: + self.log.warning('Host name %s does not match backup name %s' % + (api.env.host, self.backup_host)) + if (not options.unattended and + not user_input("Continue to restore?", False)): + raise admintool.ScriptError("Aborted") + if self.backup_ipa_version != str(version.VERSION): + self.log.warning( + "Restoring data from a different release of IPA.\n" + "Data is version %s.\n" + "Server is running %s." % + (self.backup_ipa_version, str(version.VERSION))) + if (not options.unattended and + not user_input("Continue to restore?", False)): + raise admintool.ScriptError("Aborted") + + # Big fat warning + if (not options.unattended and + not user_input("Restoring data will overwrite existing live data. Continue to restore?", False)): + raise admintool.ScriptError("Aborted") + + self.log.info( + "Each master will individually need to be re-initialized or") + self.log.info( + "re-created from this one. The replication agreements on") + self.log.info( + "masters running IPA 3.1 or earlier will need to be manually") + self.log.info( + "re-enabled. See the man page for details.") + + self.log.info("Disabling all replication.") + self.disable_agreements() + + self.extract_backup(options.gpg_keyring) + if options.data_only: + if not options.online: + self.log.info('Stopping Directory Server') + dirsrv.stop(capture_output=False) + else: + self.log.info('Starting Directory Server') + dirsrv.start(capture_output=False) + else: + self.log.info('Stopping IPA services') + (stdout, stderr, rc) = run(['ipactl', 'stop'], raiseonerr=False) + if rc not in [0, 6]: + self.log.warn('Stopping IPA failed: %s' % stderr) + + + # We do either a full file restore or we restore data. + if self.backup_type == 'FULL' and not options.data_only: + if options.online: + raise admintool.ScriptError('File restoration cannot be done online.') + self.file_restore(options.no_logs) + if 'CA' in self.backup_services: + self.__create_dogtag_log_dirs() + + # Always restore the data from ldif + # If we are restoring PKI-IPA then we need to restore the + # userRoot backend in it and the main IPA instance. If we + # have a unified instance we need to restore both userRoot and + # ipaca. + for instance in instances: + if os.path.exists('/var/lib/dirsrv/slapd-%s' % instance): + if options.backend is None: + self.ldif2db(instance, 'userRoot', online=options.online) + if os.path.exists('/var/lib/dirsrv/slapd-%s/db/ipaca' % instance): + self.ldif2db(instance, 'ipaca', online=options.online) + else: + self.ldif2db(instance, options.backend, online=options.online) + else: + raise admintool.ScriptError('389-ds instance %s does not exist' % instance) + + if options.data_only: + if not options.online: + self.log.info('Starting Directory Server') + dirsrv.start(capture_output=False) + else: + # explicitly enable then disable the pki tomcatd service to + # re-register its instance. FIXME, this is really wierd. + ipaservices.knownservices.pki_tomcatd.enable() + ipaservices.knownservices.pki_tomcatd.disable() + + self.log.info('Starting IPA services') + run(['ipactl', 'start']) + self.log.info('Restarting SSSD') + sssd = ipaservices.service('sssd') + sssd.restart() + finally: + try: + os.chdir(cwd) + except Exception, e: + self.log.error('Cannot change directory to %s: %s' % (cwd, e)) + shutil.rmtree(self.top_dir) + + + def get_connection(self): + ''' + Create an ldapi connection and bind to it using autobind as root. + ''' + if self._conn is not None: + return self._conn + + self._conn = ipaldap.IPAdmin(host=api.env.host, + ldapi=True, + protocol='ldapi', + realm=api.env.realm) + + try: + pw_name = pwd.getpwuid(os.geteuid()).pw_name + self._conn.do_external_bind(pw_name) + except Exception, e: + raise admintool.ScriptError('Unable to bind to LDAP server: %s' + % e) + return self._conn + + + def disable_agreements(self): + ''' + Find all replication agreements on all masters and disable them. + + Warn very loudly about any agreements/masters we cannot contact. + ''' + try: + conn = self.get_connection() + except Exception, e : + self.log.error('Unable to get connection, skipping disabling agreements: %s' % e) + return + masters = [] + dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn) + try: + entries = conn.get_entries(dn, conn.SCOPE_ONELEVEL) + except Exception, e: + raise admintool.ScriptError( + "Failed to read master data: %s" % e) + else: + masters = [ent.single_value('cn') for ent in entries] + + for master in masters: + if master == api.env.host: + continue + + try: + repl = ReplicationManager(api.env.realm, master, + self.dirman_password) + except Exception, e: + self.log.critical("Unable to disable agreement on %s: %s" % (master, e)) + + master_dn = DN(('cn', master), ('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn) + try: + services = repl.conn.get_entries(master_dn, + repl.conn.SCOPE_ONELEVEL) + except errors.NotFound: + continue + + services_cns = [s.single_value('cn') for s in services] + + hosts = repl.find_ipa_replication_agreements() + for host in hosts: + self.log.info('Disabling replication agreement on %s to %s' % (master, host)) + repl.disable_agreement(host) + + if 'CA' in services_cns: + try: + repl = get_cs_replication_manager(api.env.realm, master, + self.dirman_password) + except Exception, e: + self.log.critical("Unable to disable agreement on %s: %s" % (master, e)) + + hosts = repl.find_ipa_replication_agreements() + for host in hosts: + self.log.info('Disabling CA replication agreement on %s to %s' % (master, host)) + repl.hostnames = [master, host] + repl.disable_agreement(host) + + + def ldif2db(self, instance, backend, online=True): + ''' + Restore a LDIF backup of the data in this instance. + + If executed online create a task and wait for it to complete. + ''' + self.log.info('Restoring from %s in %s' % (backend, instance)) + + now = time.localtime() + cn = time.strftime('import_%Y_%m_%d_%H_%M_%S') + dn = DN(('cn', cn), ('cn', 'import'), ('cn', 'tasks'), ('cn', 'config')) + + ldifname = '%s-%s.ldif' % (instance, backend) + ldiffile = os.path.join(self.dir, ldifname) + + if online: + conn = self.get_connection() + ent = conn.make_entry( + dn, + { + 'objectClass': ['top', 'extensibleObject'], + 'cn': [cn], + 'nsFilename': [ldiffile], + 'nsUseOneFile': ['true'], + } + ) + ent['nsInstance'] = [backend] + + try: + conn.add_entry(ent) + except Exception, e: + raise admintool.ScriptError( + 'Unable to bind to LDAP server: %s' % e) + + self.log.info("Waiting for LDIF to finish") + wait_for_task(conn, dn) + else: + args = ['%s/ldif2db' % self.__find_scripts_dir(instance), + '-i', ldiffile] + if backend is not None: + args.append('-n') + args.append(backend) + else: + args.append('-n') + args.append('userRoot') + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + self.log.critical("ldif2db failed: %s" % stderr) + + + def bak2db(self, instance, backend, online=True): + ''' + Restore a BAK backup of the data and changelog in this instance. + + If backend is None then all backends are restored. + + If executed online create a task and wait for it to complete. + + instance here is a loaded term. It can mean either a separate + 389-ds install instance or a separate 389-ds backend. We only need + to treat PKI-IPA and ipaca specially. + ''' + if backend is not None: + self.log.info('Restoring %s in %s' % (backend, instance)) + else: + self.log.info('Restoring %s' % instance) + + cn = time.strftime('restore_%Y_%m_%d_%H_%M_%S') + + dn = DN(('cn', cn), ('cn', 'restore'), ('cn', 'tasks'), ('cn', 'config')) + + if online: + conn = self.get_connection() + ent = conn.make_entry( + dn, + { + 'objectClass': ['top', 'extensibleObject'], + 'cn': [cn], + 'nsArchiveDir': [os.path.join(self.dir, instance)], + 'nsDatabaseType': ['ldbm database'], + } + ) + if backend is not None: + ent['nsInstance'] = [backend] + + try: + conn.add_entry(ent) + except Exception, e: + raise admintool.ScriptError('Unable to bind to LDAP server: %s' + % e) + + self.log.info("Waiting for restore to finish") + wait_for_task(conn, dn) + else: + args = ['%s/bak2db' % self.__find_scripts_dir(instance), + os.path.join(self.dir, instance)] + if backend is not None: + args.append('-n') + args.append(backend) + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + self.log.critical("bak2db failed: %s" % stderr) + + + def file_restore(self, nologs=False): + ''' + Restore all the files in the tarball. + + This MUST be done offline because we directly backup the 389-ds + databases. + ''' + self.log.info("Restoring files") + cwd = os.getcwd() + os.chdir('/') + args = ['tar', + '-xzf', + os.path.join(self.dir, 'files.tar') + ] + if nologs: + args.append('--exclude') + args.append('var/log') + + (stdout, stderr, rc) = run(args, raiseonerr=False) + if rc != 0: + self.log.critical('Restoring files failed: %s', stderr) + + os.chdir(cwd) + + + def read_header(self): + ''' + Read the backup file header that contains the meta data about + this particular backup. + ''' + fd = open(self.header) + config = SafeConfigParser() + config.readfp(fd) + + self.backup_type = config.get('ipa', 'type') + self.backup_time = config.get('ipa', 'time') + self.backup_host = config.get('ipa', 'host') + self.backup_ipa_version = config.get('ipa', 'ipa_version') + self.backup_version = config.get('ipa', 'version') + self.backup_services = config.get('ipa', 'services') + + + def extract_backup(self, keyring=None): + ''' + Extract the contents of the tarball backup into a temporary location, + decrypting if necessary. + ''' + + encrypt = False + filename = None + if self.backup_type == 'FULL': + filename = os.path.join(self.backup_dir, 'ipa-full.tar') + else: + filename = os.path.join(self.backup_dir, 'ipa-data.tar') + if not os.path.exists(filename): + if not os.path.exists(filename + '.gpg'): + raise admintool.ScriptError('Unable to find backup file in %s' % self.backup_dir) + else: + filename = filename + '.gpg' + encrypt = True + + if encrypt: + self.log.info('Decrypting %s' % filename) + filename = decrypt_file(self.dir, filename, keyring) + + cwd = os.getcwd() + os.chdir(self.dir) + + args = ['tar', + '-xzf', + filename, + '.' + ] + run(args) + + pent = pwd.getpwnam(DS_USER) + os.chown(self.top_dir, pent.pw_uid, pent.pw_gid) + recursive_chown(self.dir, pent.pw_uid, pent.pw_gid) + + if encrypt: + # We can remove the decoded tarball + os.unlink(filename) + + + def __find_scripts_dir(self, instance): + """ + IPA stores its 389-ds scripts in a different directory than dogtag + does so we need to probe for it. + """ + if instance != 'PKI-IPA': + return os.path.join('/var/lib/dirsrv', 'scripts-%s' % instance) + else: + if sys.maxsize > 2**32: + libpath = 'lib64' + else: + libpath = 'lib' + return os.path.join('/usr', libpath, 'dirsrv', 'slapd-PKI-IPA') + + def __create_dogtag_log_dirs(self): + """ + If we are doing a full restore and the dogtag log directories do + not exist then tomcat will fail to start. + + The directory is different depending on whether we have a d9-based + or a d10-based installation. We can tell based on whether there is + a PKI-IPA 389-ds instance. + """ + if os.path.exists('/etc/dirsrv/slapd-PKI-IPA'): # dogtag 9 + topdir = '/var/log/pki-ca' + dirs = [topdir, + '/var/log/pki-ca/signedAudit,'] + else: # dogtag 10 + topdir = '/var/log/pki/pki-tomcat' + dirs = [topdir, + '/var/log/pki/pki-tomcat/ca', + '/var/log/pki/pki-tomcat/ca/archive', + '/var/log/pki/pki-tomcat/ca/signedAudit',] + + if os.path.exists(topdir): + return + + try: + pent = pwd.getpwnam(PKI_USER) + except KeyError: + self.log.debug("No %s user exists, skipping CA directory creation" % PKI_USER) + return + self.log.debug('Creating log directories for dogtag') + for dir in dirs: + try: + self.log.debug('Creating %s' % dir) + os.mkdir(dir, 0770) + os.chown(dir, pent.pw_uid, pent.pw_gid) + ipaservices.restore_context(dir) + except Exception, e: + # This isn't so fatal as to side-track the restore + self.log.error('Problem with %s: %s' % (dir, e)) diff --git a/ipaserver/install/replication.py b/ipaserver/install/replication.py index c1ca0aaa6..64c3902a5 100644 --- a/ipaserver/install/replication.py +++ b/ipaserver/install/replication.py @@ -18,6 +18,7 @@ # import time +import datetime import sys import os @@ -794,7 +795,7 @@ class ReplicationManager(object): except Exception, e: root_logger.debug("Failed to remove referral value: %s" % str(e)) - def check_repl_init(self, conn, agmtdn): + def check_repl_init(self, conn, agmtdn, start): done = False hasError = 0 attrlist = ['cn', 'nsds5BeginReplicaRefresh', @@ -819,16 +820,20 @@ class ReplicationManager(object): done = True hasError = 2 elif status.find("Total update succeeded") > -1: - print "Update succeeded" + print "\nUpdate succeeded" done = True elif inprogress.lower() == 'true': - print "Update in progress yet not in progress" + print "\nUpdate in progress yet not in progress" else: - print "[%s] reports: Update failed! Status: [%s]" % (conn.host, status) + print "\n[%s] reports: Update failed! Status: [%s]" % (conn.host, status) hasError = 1 done = True else: - print "Update in progress" + now = datetime.datetime.now() + d = now - start + sys.stdout.write('\r') + sys.stdout.write("Update in progress, %d seconds elapsed" % int(d.total_seconds())) + sys.stdout.flush() return done, hasError @@ -873,9 +878,11 @@ class ReplicationManager(object): def wait_for_repl_init(self, conn, agmtdn): done = False haserror = 0 + start = datetime.datetime.now() while not done and not haserror: time.sleep(1) # give it a few seconds to get going - done, haserror = self.check_repl_init(conn, agmtdn) + done, haserror = self.check_repl_init(conn, agmtdn, start) + print "" return haserror def wait_for_repl_update(self, conn, agmtdn, maxtries=600): @@ -1070,7 +1077,8 @@ class ReplicationManager(object): self.setup_agreement(r_conn, self.conn.host, isgssapi=True) def initialize_replication(self, dn, conn): - mod = [(ldap.MOD_ADD, 'nsds5BeginReplicaRefresh', 'start')] + mod = [(ldap.MOD_ADD, 'nsds5BeginReplicaRefresh', 'start'), + (ldap.MOD_REPLACE, 'nsds5ReplicaEnabled', 'on')] try: conn.modify_s(dn, mod) except ldap.ALREADY_EXISTS: @@ -1081,9 +1089,10 @@ class ReplicationManager(object): newschedule = '2358-2359 0' filter = self.get_agreement_filter(host=hostname) - entries = conn.get_entries( - DN(('cn', 'config')), ldap.SCOPE_SUBTREE, filter) - if len(entries) == 0: + try: + entries = conn.get_entries( + DN(('cn', 'config')), ldap.SCOPE_SUBTREE, filter) + except errors.NotFound: root_logger.error("Unable to find replication agreement for %s" % (hostname)) raise RuntimeError("Unable to proceed") @@ -1405,3 +1414,137 @@ class ReplicationManager(object): self.conn.update_entry(entry) return True + + def disable_agreement(self, hostname): + """ + Disable the replication agreement to hostname. + """ + cn, dn = self.agreement_dn(hostname) + + entry = self.conn.get_entry(dn) + entry['nsds5ReplicaEnabled'] = 'off' + + try: + self.conn.update_entry(entry) + except errors.EmptyModlist: + pass + + def enable_agreement(self, hostname): + """ + Enable the replication agreement to hostname. + + Note: for replication to work it needs to be enabled both ways. + """ + cn, dn = self.agreement_dn(hostname) + + entry = self.conn.get_entry(dn) + entry['nsds5ReplicaEnabled'] = 'on' + + try: + self.conn.update_entry(entry) + except errors.EmptyModlist: + pass + +class CSReplicationManager(ReplicationManager): + """ReplicationManager specific to CA agreements + + Note that in most cases we don't know if we're connecting to an old-style + separate PKI DS, or to a host with a merged DB. + Use the get_cs_replication_manager function to determine this and return + an appropriate CSReplicationManager. + """ + + def __init__(self, realm, hostname, dirman_passwd, port): + super(CSReplicationManager, self).__init__( + realm, hostname, dirman_passwd, port, starttls=True) + self.suffix = DN(('o', 'ipaca')) + self.hostnames = [] # set before calling or agreement_dn() will fail + + def agreement_dn(self, hostname, master=None): + """ + Construct a dogtag replication agreement name. This needs to be much + more agressive than the IPA replication agreements because the name + is different on each side. + + hostname is the local hostname, not the remote one, for both sides + NOTE: The agreement number is hardcoded in dogtag as well + + TODO: configurable instance name + """ + dn = None + cn = None + if self.conn.port == 7389: + instance_name = 'pki-ca' + else: + instance_name = dogtag.configured_constants(api).PKI_INSTANCE_NAME + + # if master is not None we know what dn to return: + if master is not None: + if master is True: + name = "master" + else: + name = "clone" + cn="%sAgreement1-%s-%s" % (name, hostname, instance_name) + dn = DN(('cn', cn), self.replica_dn()) + return (cn, dn) + + for host in self.hostnames: + for master in ["master", "clone"]: + try: + cn="%sAgreement1-%s-%s" % (master, host, instance_name) + dn = DN(('cn', cn), self.replica_dn()) + self.conn.get_entry(dn) + return (cn, dn) + except errors.NotFound: + dn = None + cn = None + + raise errors.NotFound(reason='No agreement found for %s' % hostname) + + def delete_referral(self, hostname, port): + dn = DN(('cn', self.suffix), ('cn', 'mapping tree'), ('cn', 'config')) + entry = self.conn.get_entry(dn) + try: + # TODO: should we detect proto somehow ? + entry['nsslapd-referral'].remove('ldap://%s/%s' % + (ipautil.format_netloc(hostname, port), self.suffix)) + self.conn.update_entry(entry) + except Exception, e: + root_logger.debug("Failed to remove referral value: %s" % e) + + def has_ipaca(self): + try: + entry = self.conn.get_entry(self.suffix) + except errors.NotFound: + return False + else: + return True + +def get_cs_replication_manager(realm, host, dirman_passwd): + """Get a CSReplicationManager for a remote host + + Detects if the host has a merged database, connects to appropriate port. + """ + + # Try merged database port first. If it has the ipaca tree, return + # corresponding replication manager + # If we can't connect to it at all, we're not dealing with an IPA master + # anyway; let the exception propagate up + # Fall back to the old PKI-only DS port. Check that it has the ipaca tree + # (IPA with merged DB theoretically leaves port 7389 free for anyone). + # If it doesn't, raise exception. + ports = [ + dogtag.Dogtag10Constants.DS_PORT, + dogtag.Dogtag9Constants.DS_PORT, + ] + for port in ports: + root_logger.debug('Looking for PKI DS on %s:%s' % (host, port)) + replication_manager = CSReplicationManager( + realm, host, dirman_passwd, port) + if replication_manager.has_ipaca(): + root_logger.debug('PKI DS found on %s:%s' % (host, port)) + return replication_manager + else: + root_logger.debug('PKI tree not found on %s:%s' % (host, port)) + + raise errors.NotFound(reason='Cannot reach PKI DS at %s on ports %s' % (host, ports)) |