#!/usr/bin/python3

# Copyright (C) 2006-2010 Lattica, Inc.
#
# SafeKeep is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# Safekeep 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 Safekeep.  If not, see <http://www.gnu.org/licenses/>.

import getopt, os, re, sys, fnmatch, stat
import subprocess, tempfile, time, traceback, shlex
import getpass, pwd, xml.dom.minidom
import socket, smtplib, io
import fcntl

from subprocess import PIPE, STDOUT

######################################################################
# Global settings
######################################################################

config_file = '/etc/safekeep/safekeep.conf'
config_ext = '.backup'
trickle_cmd = 'trickle'
logbuf = []
is_client = False
verbosity_level = 1
verbosity_ssh = ''
verbosity_trickle = ''
work_user = getpass.getuser()
backup_user = None
backup_tempdir = None
client_user = 'root'
home_dir = None
base_dir = None
client_defaults = []
current_pid = os.getpid()
default_bandwidth = {}
default_snapshot = '20%FREE'
statistics = []
error_counter = 0
warning_counter = 0
ssh_keygen_type = 'rsa'
ssh_keygen_bits = 4096
SSH_TYPES = ['dsa', 'rsa', 'ed25519', 'ecdsa']
SSH_KEY_TYPES = ['ssh-dss', 'ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521']
ssh_StrictHostKeyChecking = 'ask'
SSH_STRICT_HOSTKEY_CHECK_OPTS = ['ask', 'yes', 'no' ]
# Default mount options, overridden elsewhere:
# Key is a file system type, or 'snapshot' for default for snapshot mount
# or 'bind' for a bind mount (check mount for details)
default_mountoptions = {'xfs' : 'ro,nouuid', 'snapshot' : 'ro', 'bind' : 'ro'}

PROTOCOL = "1.4"
VERSION = "1.5.1"
VEBOSITY_BY_CLASS = {'DBG': 3, 'INFO': 2, 'WARN': 1, 'ERR': 0}

######################################################################
# Miscellaneous support functions
######################################################################

class ClientException(Exception):
    def __init__(self, value, traceback=None):
        self.value = value
        self.traceback = traceback

    def __str__(self):
        return repr(self.value)

#def byte_or_str(arg):
#    if isinstance(arg, (str,)): # check if its in bytes
#        return arg
#    else:
#        return arg.decode('utf-8', 'backslashreplace')

def send(msg):
    print(msg)
    sys.stdout.flush()

def log(msg, cls=None):
    global logbuf
    if cls:
        if is_client: cls = cls.lower()
        msg = '%s: %s' % (cls, msg)
    else:
        for c in list(VEBOSITY_BY_CLASS.keys()):
            if msg.upper().startswith(c + ': '):
                cls = c
                break
        else:
            cls = 'UNK'

    cutoff = VEBOSITY_BY_CLASS.get(cls.upper())
    if cutoff is None: cutoff = 3
    if is_client or verbosity_level >= cutoff:
        logbuf.append(msg)
        if is_client:
            send(msg)
        else:
            print(msg, file=sys.stderr)

def info_file(filename, marker=None, stats=None):
    info('# File: ' + filename)
    errs = 0
    fin = open(filename, 'r')
    try:
        for line in fin.readlines():
            if marker:
                if line.startswith(marker):
                    marker = None
                continue
            if line.startswith("Errors "):
                errs = int(line[6:])
            info(line.rstrip())
            if stats:
                vals = line.rstrip().split(None, 2)
                key = vals.pop(0)
                if len(vals) == 2:
                    stats[key] = vals[1][1:-1]
                else:
                    stats[key] = vals[0]
    finally:
        fin.close()
    return errs

def stacktrace():
    exc_file = io.StringIO()
    traceback.print_exc(None, exc_file)
    return "\n" + exc_file.getvalue()

def debug(msg):
    log(msg, 'DBG')

def info(msg):
    log(msg, 'INFO')

def warn(msg):
    log(msg, 'WARN')

def error(msg, ex=None):
    extra = ""
    if ex and verbosity_level > 2:
        extra = stacktrace()
    log(msg + extra, 'ERR')

def do_spawn(args, stdin=None, stdout=False):
    if isinstance(args, (str,)):
        debug('Run [' + args + ']')
    else:
        debug('Run [' + ' '.join(args) + ']')

    _shell = isinstance(args, (str,))
    if stdin:
        _stdin = PIPE
    else:
        _stdin = None
    if stdout:
        _stderr = None
    else:
        _stderr = STDOUT

    proc = subprocess.Popen(args, bufsize=1, shell=_shell, stdin=_stdin, stdout=PIPE, stderr=_stderr, close_fds=True, encoding='utf-8', errors='backslashreplace')
    child_in = proc.stdin
    child_out = proc.stdout

    if stdin:
        child_in.write(stdin)
        child_in.close()

    lines = []
    for line in child_out:
        lines.append(line)
        if not stdout:
            info(line.rstrip())
    child_out.close()

    return (proc.wait(), lines)

def _spawn(args, stdin=None, stdout=False):
    if isinstance(args, (str,)):
        cmd = args.split(None)[0]
    else:
        cmd = args[0]

    try:
        rc, out = do_spawn(args, stdin, stdout)
    except OSError as ex:
        ret = "OSError: %s" % (ex)
        error('%s failed: %s' % (cmd, ret))
        return ret

    if not rc:
        ret = None
    elif rc > 0:
        ret = 'exited with non zero status: %d' % rc
    elif rc < 0:
        ret = 'killed by signal: %d' % -rc
    else:
        ret = 'unknown exit status: %d' % rc
    if ret:
        error('%s failed: %s' % (cmd, ret))
    return (ret, out)

# this just spawns an external program (optionally through a shell)
# and returns True it it fails, and False if it successed
def spawn(args):
    rc, out = _spawn(args)
    return rc

# this spawns an external program (optionally through a shell),
# feeds it any input via stdin, captures the output and returns it.
# if it fails it returns None, otherwise it returns the output
def call(args, stdin=None):
    rc, out = _spawn(args, stdin, stdout=True)
    if rc:
        return None
    return out

def try_to_run(args):
    try:
        rc, out = do_spawn(args, None, True)
    except OSError as ex:
        return None
    if not rc in (0, 1):
        return None
    return out or ''

#
# Statistics format routines for the "server" type
#
def print_stats_table_server_text(stats):
    result = '|{0:<8}|{1:<8}'.format(stats['id'], stats['state'])
    if len(stats) > 2:
        result += '|{0:>6}|{1:<24}|{2:<24}|{3:>24}|{4:>12}|{5:>12}|{6:>13}|'.format(
                            stats['Errors'],
                            stats['StartTime'],
                            stats['EndTime'],
                            stats['ElapsedTime'],
                            stats['SourceFileSize'],
                            stats['MirrorFileSize'],
                            stats['TotalDestinationSizeChange'])
    else:
        result += '|{0:>6}|{0:<24}|{0:<24}|{0:>24}|{0:>12}|{0:>12}|{0:>13}|'.format('')
    return result

def print_stats_table_server_html(stats):
    if stats.get('state') == 'OK':
        color = ' bgcolor="#81F7BE"'
    elif 'CLEAN' in stats.get('state'):
        color = ' bgcolor="#66CCFF"'
    elif 'WARNING' in stats.get('state'):
        color = ' bgcolor="#FFCC66"'
    else:
        color = ' bgcolor="#F78181"'
    result = '<tr' + color + '><td>' + stats['id'] + '</td><td>' + stats['state'] + '</td>'
    if len(stats) > 2:
        result += '<td align="right">' + stats['Errors'] + '</td>' + \
                  '<td>' + stats['StartTime'] + '</td>' + \
                  '<td>' + stats['EndTime']  + '</td>' + \
                  '<td align="right">' + stats['ElapsedTime'] + '</td>' + \
                  '<td align="right">' + stats['SourceFileSize'] + '</td>' + \
                  '<td align="right">' + stats['MirrorFileSize'] + '</td>' + \
                  '<td align="right">' + stats['TotalDestinationSizeChange'] + '</td>'
    else:
        result += '<td align="right"></td>' + \
                  '<td></td>' + \
                  '<td></td>' + \
                  '<td align="right"></td>' + \
                  '<td align="right"></td>' + \
                  '<td align="right"></td>' + \
                  '<td align="right"></td>'
    result += '</tr>'
    return result

def stats_to_table_server_text():
    result = ['-' * 141 + '\r\n',
              '|{0:<8}|{1:<8}|{2:<6}|{3:<24}|{4:<24}|{5:<24}|{6:<12}|{7:<12}|{8:<13}|'.format(
                  'Name',
                  'State',
                  'Errors',
                  'Start time',
                  'End time',
                  'Elapsed time',
                  'Source size',
                  'Mirror size',
                  'Total changed') + \
              '\r\n',
              '-' * 141 + '\r\n']

    for stats in statistics:
        result.append(print_stats_table_server_text(stats) + '\r\n' + '-' * 141 + '\r\n')

    return result

def stats_to_table_server_html():
    result = ['<html><body><table border="1"><tr>'
              '<th>Name</th>'
              '<th>State</th>'
              '<th>Errors</th>'
              '<th>Start time</th>'
              '<th>End time</th>'
              '<th>Elapsed time</th>'
              '<th>Source size</th>'
              '<th>Mirror size</th>'
              '<th>Total changed</th>'
              '</tr>']

    for stats in statistics:
        result.append(print_stats_table_server_html(stats) + '\r\n')

    result.append('</table></html>')
    return result

#
# Statistics format routines for the "list" type
#
def print_stats_table_list_text(stats):
    result = '|{0:<8}|{1:<8}'.format(stats['id'], stats['state'])
    if len(stats) > 2:
        result += '|{0:<24}|{1:<24}|{2:>10}|'.format(
                            stats['CurrentMirror'],
                            stats['OldestIncrement'],
                            stats['Increments'])
    else:
        result += '|{0:<24}|{0:<24}|{0:>10}|'.format('')
    return result

def print_stats_table_list_html(stats):
    if stats.get('state') == 'OK':
        color = ' bgcolor="#81F7BE"'
    else:
        color = ' bgcolor="#F78181"'
    result = '<tr' + color + '><td>' + stats['id'] + '</td><td>' + stats['state'] + '</td>'
    if len(stats) > 2:
        result += '<td>' + stats['CurrentMirror'] + '</td>' + \
                  '<td>' + stats['OldestIncrement']  + '</td>' + \
                  '<td align="right">' + stats['Increments'] + '</td>'
    else:
        result += '<td></td>' + \
                  '<td></td>' + \
                  '<td align="right"></td>'
    result += '</tr>'
    return result

def stats_to_table_list_text():
    result = ['-' * 80 + '\r\n',
              '|{0:<8}|{1:<8}|{2:<24}|{3:<24}|{4:<10}|'.format(
                  'Name',
                  'State',
                  'Current Mirror',
                  'Oldest Increment',
                  'Increments') + \
              '\r\n',
              '-' * 80 + '\r\n']

    for stats in statistics:
        result.append(print_stats_table_list_text(stats) + '\r\n' + '-' * 80 + '\r\n')

    return result

def stats_to_table_list_html():
    result = ['<html><body><table border="1"><tr>'
              '<th>Name</th>'
              '<th>State</th>'
              '<th>Current Mirror</th>'
              '<th>Oldest Increment</th>'
              '<th>Increments</th>'
              '</tr>']

    for stats in statistics:
        result.append(print_stats_table_list_html(stats) + '\r\n')

    result.append('</table></html>')
    return result

#
# Main statistics printing functions
#
def stats_to_table(mode, fmt):
    if not mode in ('server', 'list'): return 'Mode: %s: not currently supported' % mode
    if not fmt in ('html', 'text'): return 'Format: %s: not currently supported' % fmt
    if len(statistics) == 0: return 'No statistics available'

    try:
        result = eval('stats_to_table_' + mode + '_' + fmt + '()')
    except NameError as ex:
        error('ERROR: %s' % (ex or ''), ex)
        result = 'Internal error: no statistics available'

    return result

def send_notification(email, mode):
    global logbuf, error_counter, warning_counter
    if not logbuf: return
    email_to = email.get('to')
    if not email_to: return
    if 'smtp' in email:
        if 'port' in email['smtp']:
            portstr = ':%d' % email['smtp']['port']
        else:
            portstr = ''
        info('Sending email to %s via %s%s' % (','.join(email_to), email['smtp'].get('server', 'Local'), portstr))
    else:
        info('Sending email to %s via %s' % (','.join(email_to), 'Local'))
    hostname = socket.getfqdn()
    if 'from' in email:
        email_from = email['from']
    else:
        email_from = 'SafeKeep@' + hostname

    if error_counter > 0:
        global_status = '%s errors' % error_counter
    elif warning_counter > 0:
        global_status = '%s warnings' % warning_counter
    else:
        global_status = 'OK'

    msg = 'From: ' + email_from + '\r\n' + \
          'To: ' + ', '.join(email_to) + '\r\n' + \
          'Subject: SafeKeep ' + global_status + ' for ' + hostname + '\r\n' + \
          'Date: ' + time.strftime("%a, %d %b %Y %H:%M:%S %z") + '\r\n'
    if 'format' not in email:
        msg += '\r\n' + '\r\n'.join(logbuf) + '\r\n'
    else:
        msg += 'Content-Type: multipart/mixed;boundary=safebounder001\r\n'
        if 'summary' in email and mode in ('server', 'list'):
            msg += '\r\n--safebounder001\r\n'
            if email['format'] == 'text':
                msg += 'Content-type: text/plain;charset=utf-8\r\n'
            else:
                msg += 'Content-type: text/html;charset=utf-8\r\n'
            msg += '\r\n' + ''.join(stats_to_table(mode, email['format'])) + \
                   '\r\n'
        msg += '\r\n--safebounder001\r\n' + \
               'Content-type: text/plain;charset=utf-8\r\n' + \
               '\r\n' + '\r\n'.join(logbuf) + '\r\n' + \
               '\r\n--safebounder001--\r\n'
    if 'smtp' in email:
        server = smtplib.SMTP(email['smtp'].get('server'), email['smtp'].get('port'))
        server.sendmail(email_from, email_to, msg.encode('ascii', 'backslashreplace'))
        server.quit()
    else:
        cmd = ['/usr/sbin/sendmail', '-t', '-f', email_from]
        call(cmd, stdin=msg)

def is_temp_root(directory):
    return directory != '/'

def reroot(root, path):
    if root == '/': return path
    if root.endswith('/'): root = root[:-1]
    if not path: return root
    if path.startswith('/'): return root + path
    return os.path.join(root, path)

def parse_prop_file(filename):
    props = {}
    fin = open(filename)
    lines = fin.readlines()
    fin.close()
    for line in lines:
        line = line.strip()
        if len(line) == 0 or line[0] == '#': continue
        if '=' in line:
            key, value = line.split('=', 1)
            props[key.strip()] = value.strip()
        else:
            props[line] = None
    return props

######################################################################
# Configuration file parser
######################################################################

class ConfigException(Exception):
    def __init__(self, value):
        self.value = value
    def __str__(self):
        return repr(self.value)

def parse_true_false(el, attr, default=None):
    true_false = el.getAttribute(attr).lower()
    if true_false and not true_false in ('true', 'yes', '1', 'false', 'no', '0'):
        raise ConfigException('Option needs to be true or false: attr %s: value %s' % (attr, el.getAttribute(attr)))
    if true_false in ('true', 'yes', '1'):
        return 'true'
    elif true_false in ('false', 'no', '0'):
        return 'false'
    return default

def parse_dump(dump_el):
    dbtype = dump_el.getAttribute('type')
    if not dbtype:
        raise ConfigException('You need to specify the database type')
    if dbtype not in ('postgres', 'postgresql', 'pgsql', 'mysql'):
        raise ConfigException('Invalid database type: %s' % dbtype)
    db = dump_el.getAttribute('db')
    user = dump_el.getAttribute('user')
    dbuser = dump_el.getAttribute('dbuser')
    dbpasswd = dump_el.getAttribute('dbpasswd')
    opts = (dump_el.getAttribute('options') or '').split()

    dbfile = dump_el.getAttribute('file')
    if not dbfile:
        raise ConfigException('You need to specify where the database should be dumped')
    cleanup = parse_true_false(dump_el, 'cleanup')
    save_db_name = parse_true_false(dump_el, 'save-db-name')
    if dbtype in ('postgres', 'postgresql', 'pgsql') and db and save_db_name == 'false':
        warn('Database dump options: pgsql, save-db-name = false and dump all databases, are incompatible: save-db-name ignored')
        save_db_name = None
    return {'type' : dbtype, 'db' : db, 'user' : user, 'dbuser' : dbuser, 'dbpasswd': dbpasswd,
            'opts' : opts, 'file' : dbfile, 'cleanup' : cleanup, 'save-db-name' : save_db_name}

def parse_snap(snap_el):
    global default_snapshot
    device = snap_el.getAttribute('device')
    if not device:
        raise ConfigException('Please specify the device to be snapshot')
    if device.rfind('/') == -1 or device.endswith('/'):
        raise ConfigException('The device name seems incorrect: ' + device)
    size = snap_el.getAttribute('size') or default_snapshot
    if not size:
        raise ConfigException('Please specify the size for the snapshot')
    tags = []
    tag_el = snap_el.getAttribute('tag')
    if tag_el:
        for tag in tag_el.split(','):
            if tag:
                if not tag.startswith('@'): tag = '@' + tag.lstrip()
                tags.append(tag.strip())
            elif is_client:
                warn('Device: %s: empty tag in taglist: %s' % (device, tag_el))
    mountoptions = snap_el.getAttribute('mount-options')
    writable = parse_true_false(snap_el, 'writable')
    return {'device' : device, 'size' : size, 'tags' : tags, 'mountoptions' : mountoptions, 'snap_writable' : writable}

def parse_clude(clude_el):
    path = clude_el.getAttribute('path')
    path = path.replace('*', r'\*').replace('?', r'\?')
    path = path.replace('[', r'\[').replace(']', r'\]')
    glob = clude_el.getAttribute('glob')
    regexp = clude_el.getAttribute('regexp')
    if not path and not glob and not regexp:
        raise ConfigException('Empty ' + clude_el.tagName)
    return {'type' : clude_el.tagName, 'path' : path, 'glob' : glob, 'regexp' : regexp}

def parse_bandwidth(bw_el):
    return {
        'overall': int(bw_el.getAttribute('overall') or 0),
        'download': int(bw_el.getAttribute('download') or 0),
        'upload': int(bw_el.getAttribute('upload') or 0)
    }

def parse_data_attributes(data_el):
    return {
        'exclude-devices': parse_true_false(data_el, 'exclude-devices', 'false'),
        'exclude-sockets': parse_true_false(data_el, 'exclude-sockets', 'false'),
        'exclude-fifos': parse_true_false(data_el, 'exclude-fifos', 'false')
    }

def parse_config(backup_el, dflt_id):
    if backup_el.tagName != 'backup':
        raise ConfigException('Invalid config file, the top level element must be <backup>')
    cfg_id = backup_el.getAttribute('id')
    if not cfg_id: cfg_id = dflt_id

    if parse_true_false(backup_el, 'enabled') == 'false':
        return None

    host_el = backup_el.getElementsByTagName('host')
    if host_el:
        host = host_el[0].getAttribute('name')
        port = host_el[0].getAttribute('port')
        user = host_el[0].getAttribute('user')
        nice = host_el[0].getAttribute('nice')
        key_ctrl = host_el[0].getAttribute('key-ctrl')
        key_data = host_el[0].getAttribute('key-data')
    else:
        host = port = user = nice = key_ctrl = key_data = None
    if host and port and not port.isdigit():
        raise ConfigException('Host port must be a number: "%s"' % port)
    if host and not user:
        user = client_user
    if host and not key_ctrl:
        key_ctrl = os.path.join('.ssh', 'safekeep-server-ctrl-key')
    if host and not key_data:
        key_data = os.path.join('.ssh', 'safekeep-server-data-key')
    if key_ctrl and not os.path.isabs(key_ctrl):
        key_ctrl = os.path.join(home_dir, key_ctrl)
    if key_data and not os.path.isabs(key_data):
        key_data = os.path.join(home_dir, key_data)

    bw = {}
    bw_el = backup_el.getElementsByTagName('bandwidth')
    if len(bw_el) == 1:
        bw = parse_bandwidth(bw_el[0])
    elif len(bw_el) > 1:
        raise ConfigException('Can not have more than one bandwidth element')

    repo_el = backup_el.getElementsByTagName('repo')
    repo_dir = None
    retention = None
    if len(repo_el) == 1:
        repo_dir = repo_el[0].getAttribute('path')
        retention = repo_el[0].getAttribute('retention')
    elif len(repo_el) > 1:
        raise ConfigException('Can not have more than one repo element')
    if not repo_dir: repo_dir = cfg_id
    repo_dir = os.path.join(base_dir, repo_dir)

    options_els = backup_el.getElementsByTagName('options')
    options = []
    if len(options_els) > 0:
        for options_el in options_els[0].childNodes:
            if options_el.nodeType != options_el.ELEMENT_NODE:
                continue
            option = options_el.nodeName
            if option == 'special-files':
                warn('options element special-files is deprecated, use data attributes instead')
            if option in ('special-files', 'rdiff-backup'):
                if options_el.hasAttributes():
                    for key, value in list(options_el.attributes.items()):
                        options.append({option : {key : value}})
                else:
                    raise ConfigException('Option "%s" has no value' % option)
            else:
                raise ConfigException('Unknown option "%s"' % option)

    setup_el = backup_el.getElementsByTagName('setup')
    writable = None
    dumps = []
    snaps = []
    script = None
    run_on = 'client'
    if len(setup_el) > 0:
        writable = parse_true_false(setup_el[0], 'writable')
        dump_els = setup_el[0].getElementsByTagName('dump')
        for dump_el in dump_els:
            dumps.append(parse_dump(dump_el))
        snap_els = setup_el[0].getElementsByTagName('snapshot')
        for snap_el in snap_els:
            snaps.append(parse_snap(snap_el))
        script_el = setup_el[0].getElementsByTagName('script')
        if len(script_el) == 1:
            script = script_el[0].getAttribute('path')
            if not ':' in script:
                if is_client and os.path.isfile(script):
                    warn('Assuming client based script: %s' % script)
                    script = 'client:' + script
                else:
                    script = 'server:' + script
            run_on_conf = script_el[0].getAttribute('run-on')
            if run_on_conf is not None and len(run_on_conf.strip()) > 0:
                # assert the values
                if not run_on_conf in ['server', 'client']:
                    raise ConfigException('Incorrect configuration value for run-on attribute: %s. '
                        'Supported values are "server" and "client"' % run_on_conf)
                run_on = run_on_conf

            if run_on_conf == 'server' and script.startswith('client:'):
                raise ConfigException('Incorrect combination: run-on="server" and client-side path of a script')
        elif len(script_el) > 1:
            raise ConfigException('Can not have more than one setup script element')

    data_options = {}
    data_el = backup_el.getElementsByTagName('data')

    if len(data_el) == 1:
        data_options = parse_data_attributes(data_el[0])
        child_els = data_el[0].childNodes
        cludes = []
        for child_el in child_els:
            if child_el.nodeType != child_el.ELEMENT_NODE:
                continue
            if child_el.tagName not in ('include', 'exclude'):
                continue
            cludes.append(parse_clude(child_el))
        cludes.append({'type' : 'exclude', 'path' : '', 'glob' : '', 'regexp' : '.*'})
    elif len(data_el) > 1:
        raise ConfigException('Can not have more than one data element')
    else:
        path_xcludes = ['/dev/', '/media/', '/mnt/', '/net/', '/proc/', '/selinux/', '/sys/',
                        '/tmp/', '/var/cache', '/var/lock', '/var/run', '/var/tmp',
                        '/var/named/chroot/dev', '/var/named/chroot/proc',
                        '/var/named/chroot/var/run', '/var/named/chroot/var/tmp']
        cludes = [{'type' : 'exclude', 'path' : path, 'glob' : None, 'regexp' : None} for path in path_xcludes]

    return {'id': cfg_id, 'host' : host, 'port' : port, 'nice' : nice, 'user' : user, 'key_ctrl' : key_ctrl, 'key_data' : key_data,
            'dir' : repo_dir, 'retention' : retention, 'dumps' : dumps, 'snaps' : snaps, 'script' : script, 'mount_writable' : writable,
            'cludes' : cludes, 'data_options' : data_options, 'options' : options, 'bw' : bw, 'run_on': run_on}

def parse_locs(cfglocs):
    cfgfiles = []
    for cfg in cfglocs:
        if os.path.isdir(cfg):
            for ent in os.listdir(cfg):
                if not ent.endswith(config_ext):
                    warn('Ignoring file %s not ending in %s' % (os.path.join(cfg, ent), config_ext))
                    continue
                filepath = os.path.join(cfg, ent)
                if not os.path.isfile(filepath):
                    continue
                cfgfiles.append(filepath)
        elif os.path.isfile(cfg):
            cfgfiles.append(cfg)
        else:
            warn('Inaccessible configuration, ignoring: %s' % cfg)

    cfgs = {}
    for filepath in cfgfiles:
        filename = os.path.splitext(os.path.basename(filepath))[0]

        cfg_file = open(filepath)
        cfg_str = cfg_file.read().strip()
        cfg_file.close()

        dom = xml.dom.minidom.parseString(cfg_str)
        try:
            cfg = parse_config(dom.documentElement, filename)
        finally:
            dom.unlink()
        if not cfg: continue
        cfg['text'] = cfg_str
        if cfg['id'] in cfgs:
            raise ConfigException('Duplicate client ID: %s' % cfg['id'])
        cfgs[cfg['id']] = cfg

    return cfgs

######################################################################
# Script, DB and SNAPSHOT support
#   setup methods can raise exception to signal errors
#   teardown methods must succeed and cleanup the state
######################################################################

def check_script_permissions(script):
    if not os.path.isfile(script):
        return '%s is not a regular file' % script
    if not os.access(script, os.X_OK):
        return '%s is not executable' % script

    statinfo = os.stat(script)
    if statinfo.st_uid and statinfo.st_uid != os.getuid():
        return '%s is owned by others' % script

    if statinfo.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
        return '%s is writable by others' % script

    return None


def _call_script(step, cfg, bdir, mode):
    debug('Do %s_side_script: step %s' % (mode, step))

    ret = None
    script = cfg['script']

    if script:
        if mode == 'server': script = cfg['script'].split(':', 1)[1]
        debug('%s_side_script: script = %s' % (mode, script))
        if os.path.exists(script):
            ret = check_script_permissions(script)
            if not ret:
                ret = spawn([script, step, cfg['id'], bdir, mode])
        else:
            debug('%s_side_script: %s not found' % (mode, script))

    return ret


def client_side_script(step, cfg, bdir):
    return _call_script(step, cfg, bdir, 'client')


def server_side_script(step, cfg, bdir):
    return _call_script(step, cfg, bdir, 'server')


def do_client_dbdump(cfg):
    debug('Doing DB dumps')
    ec = 0
    for dump in cfg['dumps']:
        dbtype = dump['type']
        opts = dump['opts']
        passwdfile = None
        if dbtype in ('postgres', 'postgresql', 'pgsql'):
            if dump['db']:
                args = ['pg_dump']
                if dump['save-db-name'] != 'false':
                    args.extend(['-C'])
            else:
                args = ['pg_dumpall']
            if dump['dbuser']:
                args.extend(['-U', dump['dbuser']])
            args.extend(opts)
            if dump['db']:
                args.extend([dump['db']])
            if dump['dbpasswd']:
                (fd, passwdfile) = tempfile.mkstemp()
                f = os.fdopen(fd, 'w')
                f.write('*:*:*:*:%s' % dump['dbpasswd'])
                f.close()

        elif dbtype in ('mysql'):
            args = ['mysqldump']
            if dump['dbuser']:
                args.extend(['-u', dump['dbuser']])
            if dump['dbpasswd']:
                args.extend(['-p%s' % dump['dbpasswd']])
            if not dump['db']:
                if dump['save-db-name'] == 'false':
                    args.extend(['-n', '-A'])
                else:
                    args.extend(['-A'])
            args.extend(opts)
            if dump['db']:
                if dump['save-db-name'] == 'true':
                    args.extend(['-B', dump['db']])
                else:
                    args.extend([dump['db']])

        else:
            warn('Invalid database type: ' + dbtype)
            continue

        if dump['user']:
            cmd = ' '.join([shlex.quote(arg) for arg in args])
            args = ['su', '-c', cmd, '-', dump['user']]
        cmd = ' '.join([shlex.quote(arg) for arg in args])
        cmd = '%s > %s' % (cmd, shlex.quote(dump['file']))

        if passwdfile:
            os.environ['PGPASSFILE'] = passwdfile
        try:
            ec = spawn(cmd)
        finally:
            if passwdfile:
                del os.environ['PGPASSFILE']
                os.remove(passwdfile)
        if ec:
            warn('Can not dump the database: %s' % dump['db'])
    return ec

def do_client_dbdump_teardown(cfg):
    debug('Tear down DB dumps')
    for dump in cfg['dumps']:
        if dump['cleanup'] != 'true':
            continue
        try:
            os.remove(dump['file'])
        except OSError as e:
            warn('Unable to remove dump file: %s for database %s because: %s' %
                 (dump['file'], dump['db'], e))

def lvm_snap_information():
    lines = call(['lvs', '--separator', ':', '--noheadings']) or ''
    lvms = []
    for line in lines:
        if line.count(':') > 3:
            (volume, group, attr, blah1) = line.lstrip().split(':', 3)
            if fnmatch.fnmatch(volume, '*_snap_safekeep-*') and attr[0].lower() == 's':
                lvms.append([volume, group])
    return lvms

def mount_information(reverse=False):
    lines = call(['mount']) or ''
    mounts = []
    pattern = re.compile(r"^(\S+) on (.+) type (\S+) \((\S+)\)")
    if reverse:
        lines.reverse()
    for line in lines:
        matches = pattern.match(line)
        if matches is not None:
            mounts.append(matches.groups())
    return mounts

def normalise_lvm_device(device):
    return device.replace('/mapper', '').replace('-', '/').replace('//', '-')

def map_lvm_device(device):
    return normalise_lvm_device(device).split('/')[-2:]

def check_lvm_information(device):
    (group, volume) = map_lvm_device(device)
    for (lvm_volume, lvm_group) in lvm_snap_information():
        if lvm_group == group and lvm_volume.startswith(volume):
            return True
    return False

def do_lvremove(device):
    (group, volume) = device.split('/')[-2:]
    if group == 'mapper':
        lvmdev = device
    else:
        lvmdev = '/dev/mapper/%s-%s' % (group.replace('-', '--'), volume.replace('-', '--'))
    if os.path.exists(lvmdev):
        for i in range(1, 10):
            os.sync()
            ret = spawn(['lvremove', '--force', device])
            if ret:
                ret = spawn(['dmsetup', 'remove', lvmdev])
                ret = spawn(['dmsetup', 'remove', lvmdev + '-cow'])
                ret = spawn(['lvremove', '--force', device])
            if not ret:
                break
    else:
        warn('lvremove called for non-existent device: %s' % lvmdev)
        ret = 0     # Equivalent to lvremove succeeding
    return ret

def generate_snap_information(device, bdir, mountpoint, mounttype):
    assert os.path.ismount(mountpoint)
    (group, volume) = map_lvm_device(device)
    lvmdev = os.path.join('/dev', group, volume)
    if bdir[-1] == '/': bdir = bdir[:-1]
    snapname = '%s_snap_%s' % (volume, os.path.basename(bdir))
    snapdev = os.path.join('/dev', group, snapname)
    if os.path.isabs(mountpoint[0]): mountpoint = mountpoint[1:]
    return (lvmdev, snapdev, os.path.join(bdir, mountpoint), mounttype)

def update_mountoptions(mountoptions, writable):
    if writable == 'true':
        sub = 'rw'
    else:
        sub = 'ro'
    mod = False
    options = mountoptions.split(',')
    for i in range(len(options)):
        if options[i] in ('rw', 'ro'):
            options[i] = sub
            mod = True
    if not mod: options.append(sub)
    return ','.join(options)

def do_client_snap_device(snap, bdir, mountpoint, mounttype, writable):
    assert is_temp_root(bdir)
    device = snap['device']
    debug('Doing FS snapshot for %s' % device)
    (lvmdev, snapdev, snapmnt, snaptyp) = generate_snap_information(device, bdir, mountpoint, mounttype)
    if not os.path.isdir(snapmnt):
        warn('Cannot find the mountpoint %s for %s' % (snapmnt, device))
        return False
    if 'snapdevice' in snap:
        warn('bind mount of snapshot not currently supported: %s' % mountpoint)
        return True     # Should be False, but this will still do a good backup
    args = ['lvcreate']
    if snap['tags']:
        for tag in snap['tags']:
            args.extend(['--addtag', tag.strip()])
    size = snap['size']
    if size.count('%'):
        if size.endswith('%'): size += 'ORIGIN'
        args.extend(['--snapshot', '--extents', size])
    else:
        args.extend(['--snapshot', '--size', size])
    args.extend(['--name', os.path.basename(snapdev), lvmdev])
    os.sync()	# Should not be needed except for very old kernels
    ec = spawn(args)
    if ec:
        warn('Can not snapshot the device: %s' % device)
        return False

    # no need to mkdir since the mountpoint already exists
    args = ['mount', '-t', snaptyp]
    if snap['mountoptions']:
        mountoptions = snap['mountoptions']
    elif snaptyp in default_mountoptions:
        mountoptions = default_mountoptions[snaptyp]
    elif 'snapshot' in default_mountoptions:
        mountoptions = default_mountoptions['snapshot']
    if snap['snap_writable']:
        writable = snap['snap_writable']
    if writable:
        mountoptions = update_mountoptions(mountoptions, writable)
    if mountoptions:
        args.extend(['-o', mountoptions])
    args.extend([snapdev, snapmnt])
    ec = spawn(args)
    if ec:
        warn('Can not mount the snapshot: %s' % device)
        ret = do_lvremove(snapdev)
        if ret:
            warn('Can not tear down snapshot: %s' % device)
        return False
    snap['snapdevice'] = snapdev
    snap['mountpoint'] = snapmnt
    return True

def do_client_snap_teardown(cfg, bdir):
    assert is_temp_root(bdir)
    debug('Tear down FS snapshots dumps')
    snaps = list(cfg['snaps'])
    snaps.reverse()
    for snap in snaps:
        if 'mountpoint' in snap: del snap['mountpoint']
        device = snap['device']
        if not 'snapdevice' in snap:
            warn('No snapdevice for device teardown: %s' % device)
            continue

        snapdev = snap['snapdevice']
        debug('Tear down FS snapshot dump for %s -> %s' % (snapdev, device))

        ret = do_lvremove(snapdev)
        if ret:
            warn('Can not tear down snapshot: %s' % device)
            continue
        del snap['snapdevice']

def find_snapshot(cfg, device):
    for snap in cfg['snaps']:
        if normalise_lvm_device(snap['device']) == normalise_lvm_device(device):
            debug('find_snapshot device matched: %s' % device)
            return snap
    debug('find_snapshot device no matched for %s' % device)
    return None

def mount_excluded(cfg, mountpoint):
    debug("mount_excluded: %s" % mountpoint)
    if not mountpoint.endswith('/'): mountpoint = mountpoint + '/'
    for clude in cfg['cludes']:
        if clude['type'] == 'exclude' and clude['path']:
            if mountpoint.startswith(clude['path']):
                debug("mount_excluded: %s: matched %s" % (mountpoint, clude['path']))
                return True
    debug("mount_excluded: %s: no matches" % mountpoint)
    return False

def do_umount_all(bdir):
    assert is_temp_root(bdir)
    total_ret = 0
    for (device, mountpoint, mounttype, mountoptions) in mount_information(True):
        if mountpoint.startswith(bdir):
            debug("Removing mount %s" % mountpoint)
            ret = spawn(['umount', mountpoint])
            if ret:
                warn('Can not unmount snapshot: %s' % mountpoint)
                total_ret += ret
    return total_ret

def do_rbind(cfg, startpath, bdir):
    for (device, mountpoint, mounttype, mountoptions) in mount_information(False):
        debug("Testing %s on %s" % (mountpoint, device))
        if mountpoint.startswith(startpath) and device.startswith('/'):
            if not mount_excluded(cfg, mountpoint):
                if 'bind' in mountoptions.split(','):
                    warn('bind mount of snapshot not currently supported: %s' % mountpoint)
                    continue
                snap = find_snapshot(cfg, device)
                if snap:
                    ret = not do_client_snap_device(snap, bdir, mountpoint, mounttype, cfg['mount_writable'])
                else:
                    ret = spawn(['mount', '--bind', mountpoint, reroot(bdir, mountpoint)])
                    if ret:
                        debug("mount --bind %s: failed: unwinding" % mountpoint)
                    else:
                        spawn(['mount', '--make-unbindable', reroot(bdir, mountpoint)])
                        if 'bind' in default_mountoptions:
                            mountoptions = default_mountoptions['bind']
                        if cfg['mount_writable']:
                            mountoptions = update_mountoptions(mountoptions, cfg['mount_writable'])
                        if mountoptions:
                            spawn(['mount', '-o',
                                ('remount,%s,bind' % mountoptions),
                                mountpoint, reroot(bdir, mountpoint)])
                if ret:
                    ret = spawn(['umount', reroot(bdir, startpath)])
                    if ret:
                        warn('Failed to unmount: %s' % reroot(bdir, startpath))
                    do_client_snap_teardown(cfg, bdir)
                    return 1

    return 0

######################################################################
# Client implementation
######################################################################

def do_client_config(cmd):
    cfgStr = ''

    (cfg_cmd, cnt_str, dflt_id) = cmd.split(':', 2)
    for i in range(int(cnt_str)):
        line = sys.stdin.readline()
        if not line: raise ConfigException('Unexpected end of file')
        cfgStr += line

    return do_client_config_parse(cfgStr, dflt_id)

def do_client_config_parse(cfgStr, dflt_id=None):
    dom = xml.dom.minidom.parseString(cfgStr)
    try:
        return parse_config(dom.documentElement, dflt_id)
    finally:
        dom.unlink()

def do_client_remote_script(script_file, cfg, cmd):
    (cfg_cmd, server_file, cnt_str) = cmd.split(':', 2)
    debug("do_client_remote_script: %s -> %s: cnt_str = %s" % (server_file, cfg['script'], cnt_str.strip()))
    if int(cnt_str) > 0:
        try:
            for i in range(int(cnt_str)):
                line = sys.stdin.readline()
                if not line: raise Exception('Unexpected end of file')
                script_file.write(line)
        finally:
            script_file.close()

        os.chmod(cfg['script'], stat.S_IXUSR | stat.S_IRUSR)
        return True
    else:
        script_file.close()
        os.remove(cfg['script'])

    return False

def do_client_setup(cfg):
    debug('Do setup of %s' % cfg['host'])

    total_ret = do_client_dbdump(cfg)

    if len(cfg['snaps']) > 0:
        debug('Checking FS snapshots')
        for snap in cfg['snaps']:
            device = snap['device']
            if check_lvm_information(device) and not do_client_scrub():
                raise Exception("Previous snapshots found for %s and automatic correction failed: run 'safekeep --server --cleanup %s' to correct" % (device, cfg['host']))

        ret = spawn(['modprobe', 'dm-snapshot'])
        if ret:
            warn('modprobe dm-snapshot failed, continuing')
            total_ret += ret
        bdir = tempfile.mkdtemp(suffix="-rbind", prefix="safekeep-%d-" % current_pid, dir="/mnt")
        ret = do_rbind(cfg, '/', bdir)
        if ret:
            warn('mount --rbind failed, snapshotting will be disabled')
            total_ret += ret
            try:
                os.rmdir(bdir)
            except OSError as e:
                warn('Failed to remove: %s: %s' % (bdir, e))
                total_ret += 1
            bdir = '/'
    else:
        bdir = '/'
    debug('Working root is %s' % bdir)

    return (bdir, total_ret)

def do_client_cleanup(cfg, bdir):
    debug('Do cleanup of %s in %s' % (cfg['host'], bdir))
    if is_temp_root(bdir):
        ret = do_umount_all(bdir)
        if ret:
            warn('Failed to unmount tree: %s' % bdir)
        else:
            try:
                os.rmdir(bdir)
            except OSError as e:
                warn('Unable to remove: %s: %s' % (bdir, e))

        do_client_snap_teardown(cfg, bdir)

    do_client_dbdump_teardown(cfg)

def do_client_compat(server_versions):
    debug('Server versions: %s' % server_versions)
    (server_protocol, server_version) = server_versions.split(',')
    compat = {
        'client': get_protocol_info(PROTOCOL, True),
        'server': get_protocol_info(server_protocol, False)
    }

    return compat


def do_client_scrub():
    debug("Do client scrub loop")

    if os.getuid():
        if is_client:
            raise Exception('client not running as root')
        else:
            warn('--cleanup should be run as root on client')
            info('No cleanup performed')
    else:
        scrubbed = False

        # Go through and see if any come from existing safekeep processes
        pattern = re.compile(r"_snap_safekeep-(\d+)-")
        for (volume, group) in lvm_snap_information():
            matches = pattern.search(volume)
            if matches is not None:
                pid = matches.group(1)
                # Look up /proc/<pid>/cmdline to see what process is running
                proc_file = "/proc/" + pid + "/cmdline"
                if pid != current_pid and os.path.exists(proc_file):
                    fin = open(proc_file, "r")
                    try:
                        (cmd, arg0, args) = fin.read().split('\0', 2)
                    except:
                        arg0 = ''
                    fin.close()
                    if os.path.basename(arg0) == "safekeep":
                        raise Exception('another safekeep process running: pid %s' % pid)

        if os.environ['PATH'][-1] == ':':
            os.environ['PATH'] += '/sbin:/usr/sbin:/usr/local/sbin:'
        else:
            os.environ['PATH'] += ':/sbin:/usr/sbin:/usr/local/sbin'

        # Go through and unmount anythings that are still hanging around

        debug("Cleaning up existing mounts")
        for (device, mountpoint, mounttype, mountoptions) in mount_information(True):
            if mountpoint.startswith('/mnt/safekeep-'):
                info("Removing mount %s" % mountpoint)
                ret = spawn(['umount', mountpoint])
                if ret:
                    warn('Can not unmount the snapshot: %s' % mountpoint)
                if fnmatch.fnmatch(device, '*_snap_safekeep-*'):
                    info("Removing snapshot %s" % device)
                    ret = do_lvremove(device)
                    if ret:
                        warn('Can not tear down snapshot: %s' % device)
                scrubbed = True

        # Now cleanup any snapshots still hanging around

        debug("Cleaning up remaining snapshots")
        for (volume, group) in lvm_snap_information():
            device = os.path.join('/dev', group, volume)
            info("Removing snapshot %s" % device)
            ret = do_lvremove(device)
            if ret:
                warn('Can not tear down snapshot: %s' % device)
            scrubbed = True

        # Now cleanup any safekeep directories and script files still hanging around

        debug("Cleaning up remaining safekeep directories")
        if os.path.isdir('/mnt'):
            for ent in os.listdir('/mnt'):
                mountpoint = os.path.join('/mnt', ent)
                if ent.startswith('safekeep-') and os.path.isdir(mountpoint):
                    info("Removing rbind directory %s" % mountpoint)
                    try:
                        os.rmdir(mountpoint)
                    except OSError as e:
                        warn('Failed to remove: %s: %s' % (mountpoint, e))

        debug("Cleaning up remaining safekeep script files")
        script_dir = tempfile.gettempdir()
        if os.path.isdir(script_dir):
            for ent in os.listdir(script_dir):
                if fnmatch.fnmatch(ent, 'safekeep-[0-9][0-9]*-') and not fnmatch.fnmatch(ent, 'safekeep-%d-' % current_pid):
                    script_file = '%s/%s' % (script_dir, ent)
                    if os.path.isdir(script_file):
                        temp_dir = script_file
                        for ent in os.listdir(temp_dir):
                            script_file = '%s/%s' % (temp_dir, ent)
                            info("Removing script file %s" % script_file)
                            try:
                                os.remove('%s' % script_file)
                            except OSError as e:
                                warn('Failed to remove: %s: %s' % (script_file, e))
                        info("Removing script directory %s" % temp_dir)
                        try:
                            os.rmdir(temp_dir)
                        except OSError as e:
                            warn('Failed to remove: %s: %s' % (temp_dir, e))
                    else:
                        info("Removing script file %s" % script_file)
                        try:
                            os.remove('%s' % script_file)
                        except OSError as e:
                            warn('Failed to remove: %s: %s' % (script_file, e))

        if not scrubbed:
            info('No cleanup required')

        # This has to be rerun to see if it has been successful
        if lvm_snap_information():
            return False
        return True

    return False

def do_client():
    debug("Do client main loop")
    should_cleanup = True
    bdir = '/'
    cfg = do_client_config_parse('<backup/>', 'def')
    ex = None
    script_file = None
    script_dir = None
    try:
        while True:
            try:
                line = sys.stdin.readline()
                if line.startswith('ALOHA'):
                    compat = do_client_compat(line.strip().split(':', 1)[1])
                    send_status = ('CL_STATUS' in compat['server']['caps'])
                    send('OK %s, %s' % (PROTOCOL, VERSION))
                elif line.startswith('DEFAULT'):
                    for opts in line.strip().split(':', 1)[1].split(','):
                        opt, val = opts.strip().split('=')
                        if opt == 'snapshot.size':
                            global default_snapshot
                            default_snapshot = val
                    if send_status:
                        send('OK 0')
                    else:
                        send('OK')
                elif line.startswith('CONFIG'):
                    cfg = do_client_config(line)
                    if cfg['script'] and cfg['run_on'] == 'client':
                        if ':' in cfg['script']:
                            (script_loc, script) = cfg['script'].split(':', 1)
                        else:
                            (script_loc, script) = ('client', cfg['script'])
                        if script_loc == 'server':
                            if not script_dir:
                                script_dir = tempfile.mkdtemp(prefix="safekeep-%d-" % current_pid)
                                tempfile.tempdir = script_dir
                            script = os.path.basename(script)
                            (fd, cfg['script']) = tempfile.mkstemp(prefix="%s-" % script, dir=script_dir)
                            script_file = os.fdopen(fd, 'w')
                            if send_status:
                                send('OK %d %s' % (0, cfg['script']))
                            else:
                                send('OK %s' % cfg['script'])
                        elif script_loc == 'client':
                            cfg['script'] = script
                            ret = client_side_script('STARTUP', cfg, bdir)
                            if ret:
                                send('ERROR Client-side setup script failed: %s' % ret)
                            elif send_status:
                                send('OK 0')
                            else:
                                send('OK')
                        else:
                            warn('Unknown script location %s for script %s' % (script_loc, script))
                            if send_status:
                                send('OK 1')
                            else:
                                send('OK')
                    elif send_status:
                        send('OK 0')
                    else:
                        send('OK')
                elif line.startswith('SCRIPT'):
                    if do_client_remote_script(script_file, cfg, line):
                        ret = client_side_script('STARTUP', cfg, bdir)
                        if ret:
                            send('ERROR Client-side setup script failed: %s' % ret)
                        elif send_status:
                            send('OK 0')
                        else:
                            send('OK')
                    else:
                        script_file = None
                        cfg['script'] = None
                        if send_status:
                            send('OK 0')
                        else:
                            send('OK')
                elif line.startswith('SETUP'):
                    status = 0
                    ret = client_side_script('PRE-SETUP', cfg, bdir)
                    if ret: status += 1
                    bdir, ret = do_client_setup(cfg)
                    if ret: status += 1
                    ret = client_side_script('POST-SETUP', cfg, bdir)
                    if ret: status += 1
                    if send_status:
                        send('OK %d %s' % (status, bdir))
                    else:
                        send('OK %s' % bdir)
                elif line.startswith('CLEANUP'):
                    status = 0
                    path = line[7:].strip()
                    if path == bdir: should_cleanup = False
                    do_client_cleanup(cfg, path)
                    ret = client_side_script('POST-BACKUP', cfg, bdir)
                    if ret: status += 1
                    if send_status:
                        send('OK %d' % status)
                    else:
                        send('OK')
                elif line.startswith('SCRUB'):
                    status = 0
                    do_client_scrub()
                    ret = client_side_script('POST-SCRUB', cfg, bdir)
                    if ret: status += 1
                    if send_status:
                        send('OK %d' % status)
                    else:
                        send('OK')
                elif not line:
                    break
                else:
                    send('ERROR Unknown command: %s' % line)
                    break
            except Exception as e:
                ex = e
                break
    finally:
        if should_cleanup:
            do_client_cleanup(cfg, bdir)
        if script_file:
            if not script_file.closed: script_file.close()
            os.remove(cfg['script'])
        if script_dir:
            tempfile.tempdir = None
            try:
                os.rmdir(script_dir)
            except OSError as e:
                warn('Failed to remove: %s: %s' % (script_dir, e))

    if ex:
        send('TRACEBACK ' + str(ex)  + '>>>' + stacktrace().replace('\n', '###'))

######################################################################
# Server implementation
######################################################################

def executable_lookup(cfg, executable, location):
    key = 'exec_' + location

    for option in cfg['options']:
        if executable in option:
            if key in option[executable]:
                return option[executable][key]
    return executable

def do_server_getanswer(cout, cl_status):
    while True:
        line = cout.readline()
        if line.startswith('OK'):
            if cl_status:
                status_s = line[2:-1].strip()
                if ' ' in status_s:
                    status_s, ret = status_s.split(None, 1)
                else:
                    ret = None
                return(int(status_s), ret)
            else:
                return (0, line[2:-1].strip())
        elif line.startswith('ERROR'):
            raise ClientException(line[5:].strip())
        elif line.startswith('TRACEBACK'):
            i = line.find('>>>')
            raise ClientException(line[10:i].strip(), line[i+3:].replace('###', '\n').rstrip())
        elif not line:
            raise Exception('client died unexpectedly')
        else:
            log(line[:-1])

def do_server_rdiff(cfg, bdir, nice, ionice, force):
    args = []

    if nice:
        args.extend(['nice', '-n' + str(nice)])

    ionice_cmd = 'ionice'
    if ionice and ionice != 'none':
        ionice_out = try_to_run([ionice_cmd, '-h'])
        if ionice_out is not None:
            ionice_args = []
            if ionice == 'idle':
                ionice_args.extend(['-c3'])
            else:
                ionice_args.extend(['-c2', '-n%s' % (ionice)])

            if ''.join(ionice_out).find('-t') > 0:
                ionice_args.extend(['-t'])

            if try_to_run([ionice_cmd] + ionice_args + ['/bin/true']) is not None:
                args.append(ionice_cmd)
                args.extend(ionice_args)
            else:
                warn('ionice(1) fails, ignoring ionice.adjustment')
        else:
            warn('ionice(1) not available, ignoring ionice.adjustment')

    # handle bandwidth limiting via trickle
    def get_bw(vals, d):
        return vals.get(d) or vals.get('overall')
    def get_bandwidth(cfg, d):
        return get_bw(cfg['bw'], d) or get_bw(default_bandwidth, d)
    trickle = []
    limit_dl = get_bandwidth(cfg, 'download')
    limit_ul = get_bandwidth(cfg, 'upload')
    if limit_dl or limit_ul:
        trickle.extend([trickle_cmd])
        if verbosity_trickle: trickle.extend([verbosity_trickle])
        if limit_dl:
            trickle.extend(['-d', str(limit_dl)])
        if limit_ul:
            trickle.extend(['-u', str(limit_ul)])
    if len(trickle):
        if try_to_run([trickle_cmd, '-V']) is None:
            warn('Trickle not available, bandwidth limiting disabled')
            trickle = []
    args.extend(trickle)

    args.extend([executable_lookup(cfg, 'rdiff-backup', 'local')])

    if cfg['host']:
        basessh = 'ssh -oStrictHostKeyChecking=%s' % (ssh_StrictHostKeyChecking)
        if cfg['port']: basessh += ' -p %s' % cfg['port']
        schema = '%s %s -i %s %%s %s --server' % (basessh, verbosity_ssh, cfg['key_data'], executable_lookup(cfg, 'rdiff-backup', 'remote'))
        args.extend(['--remote-schema', schema])

    if force:
        args.extend(['--force'])

    if backup_tempdir:
        args.extend(['--tempdir', backup_tempdir])

    options_append = []

    special_files = []
    if cfg['data_options'].get('exclude-devices') == 'true':
        special_files.extend(['--exclude-device-files'])
    if cfg['data_options'].get('exclude-sockets') == 'true':
        special_files.extend(['--exclude-sockets'])
    if cfg['data_options'].get('exclude-fifos') == 'true':
        special_files.extend(['--exclude-fifos'])

    for option in cfg['options']:
        if 'special-files' in option:
            if 'include' in option['special-files']:
                if option['special-files']['include'].lower() in ('true', 'yes', '1'):
                    special_files = ['--include-special-files']

        # Note if we ever add other backends this section should only be run
        # when rback-diff is the current option.

        if 'rdiff-backup' in option:
            if 'append' in option['rdiff-backup']:
                options_append.extend(option['rdiff-backup']['append'].split(None))

    args.extend(special_files)
    args.extend(options_append)

    for clude in cfg['cludes']:
        opt = '--' + clude['type']
        if clude['path']:
            args.extend([opt, reroot(bdir, clude['path'])])
        if clude['glob']:
            args.extend([opt, reroot(bdir, clude['glob'])])
        if clude['regexp']:
            args.extend([opt + '-regexp', bdir + clude['regexp']])

    userhost = ''
    if cfg['host']:
        userhost = '%s@%s::' % (cfg['user'], cfg['host'])
    args.extend([userhost + bdir, cfg['dir']])
    ret = spawn(args)
    if ret:
        raise Exception('Failed to run %s' % executable_lookup(cfg, 'rdiff-backup', 'local'))

def do_server_rdiff_cleanup(cfg):
    args = [executable_lookup(cfg, 'rdiff-backup', 'local')]
    if backup_tempdir:
        args.extend(['--tempdir', backup_tempdir])
    args.extend(['--check-destination-dir', cfg['dir']])
    ret = spawn(args)
    if ret:
        warn('Failed to cleanup old data, please fix the problem manually')

def do_server_data_cleanup(cfg):
    args = [executable_lookup(cfg, 'rdiff-backup', 'local')]
    if backup_tempdir:
        args.extend(['--tempdir', backup_tempdir])
    args.extend(['--force', '--remove-older-than', cfg['retention'], cfg['dir']])
    ret = spawn(args)
    if ret:
        warn('Failed to cleanup old data, please fix the problem manually')

def get_protocol_info(protocol, is_client):
    (major_s, minor_s) = protocol.strip().split('.')
    major = int(major_s)
    minor = int(minor_s)

    caps = []
    if major == 1:
        if minor >= 3:
            caps.append('DEFAULT')
        if minor >= 4:
            caps.append('CL_STATUS')

    return {
        'version': (major, minor),
        'caps': caps
    }

def do_server_compat(client_versions):
    (client_protocol, client_version) = client_versions.split(',')
    compat = {
        'client': get_protocol_info(client_protocol, True),
        'server': get_protocol_info(PROTOCOL, False)
    }

    (server_major, server_minor) = PROTOCOL.split('.')
    if compat['server']['version'][0] != compat['client']['version'][0]:
        raise Exception('Incompatible protocols: %s <> %s' % (PROTOCOL, client_protocol))
    elif compat['server']['version'][1] > compat['client']['version'][1]:
        info('Protocol mismatch, but compatible: %s <> %s' % (PROTOCOL, client_protocol))
    return compat

def do_server(cfgs, ids, nice, ionice, force, cleanup):
    global statistics, client_defaults, error_counter, warning_counter
    debug("Do server main loop")
    output_done = False
    for cfg in sorted(iter(cfgs.values()), key=lambda cfg: cfg['id']):
        cfg_id = cfg['id']
        if ids and cfg_id not in ids: continue
        info('------------------------------------------------------------------')
        stats = {}
        info('Server backup starting for client %s' % cfg_id)
        stats['id'] = cfg_id
        output_done = True

        cleaned_up = not cleanup
        try:
            cin = None
            cout = None
            fbm = None
            setup_errs = 0
            if cfg['host']:
                if not os.path.isfile(cfg['key_ctrl']):
                    raise Exception('Client %(id)s missing ctrl key %(key_ctrl)s' % cfg)
                if not os.path.isfile(cfg['key_data']):
                    raise Exception('Client %(id)s missing data key %(id)s' % cfg)

            datadir = os.path.join(os.getcwd(), cfg['dir'])
            if not os.path.isdir(datadir):
                try:
                    os.makedirs(datadir)
                except EnvironmentError as ex:
                    raise Exception('Can not create data store dir: %s: %s' % (datadir, ex))

            rdiff_logdir = os.path.join(datadir, 'rdiff-backup-data')

            backup_log = os.path.join(rdiff_logdir, 'backup.log')
            if os.path.isfile(backup_log):
                fbm = open(backup_log, 'a')
                try:
                    fcntl.flock(fbm.fileno(), fcntl.LOCK_EX|fcntl.LOCK_NB)
                except IOError as ex:
                    warn('Another backup or cleanup is in progress for client %s' % cfg_id)
                    raise Exception('Another backup or cleanup is in progress')

            if cfg['retention'] and os.path.isdir(rdiff_logdir) and not cleanup:
                do_server_data_cleanup(cfg)

            cmd = []
            if cfg['host']:
                cmd.extend(['ssh'])
                if verbosity_ssh: cmd.extend([verbosity_ssh])
                if cfg['port']: cmd.extend(['-p', cfg['port']])
                cmd.extend(['-oStrictHostKeyChecking=%s' % (ssh_StrictHostKeyChecking)])
                cmd.extend(['-T', '-i', cfg['key_ctrl'], '-l', cfg['user'], cfg['host']])
            cmd.extend(['safekeep', '--client'])

            subp = subprocess.Popen(cmd, stdin=PIPE, stdout=PIPE, encoding='utf-8', errors='backslashreplace')
            cin = subp.stdin
            cout = subp.stdout

            cin.write('ALOHA: %s, %s\n' % (PROTOCOL, VERSION))
            cin.flush()
            client_versions = (do_server_getanswer(cout, False))[1]
            compat = do_server_compat(client_versions)
            cl_status = ('CL_STATUS' in compat['client']['caps'])

            # This test will need to be improved for later PROTOCOL versions.
            if ('DEFAULT' in compat['client']['caps']) and len(client_defaults):
                cin.write('DEFAULT: %s\n' % (','.join(client_defaults)))
                cin.flush()
                setup_errs += (do_server_getanswer(cout, cl_status))[0]

            cin.write('CONFIG: %d: %s\n' % (len(cfg['text'].splitlines()), cfg_id))
            cin.write(cfg['text'] + '\n')
            cin.flush()
            errs, remote_script = do_server_getanswer(cout, cl_status)
            setup_errs += errs
            # run a server-side hook as the first thing
            if cfg['script'] and cfg['run_on'] == 'server':
                ret = server_side_script('STARTUP', cfg, datadir)
                if ret:
                    error('Server-side setup script failed: %s' % ret)
                    raise Exception('Server-side setup script failed: %s' % ret)

            if cfg['script'] and cfg['script'].startswith('server:')\
                    and remote_script and cfg['run_on'] == 'client':
                local_script = cfg['script'].split(':', 1)[1]
                if os.path.isfile(local_script):
                    ret = check_script_permissions(local_script)
                    if not ret:
                        debug("Transferring script: %s -> %s" % (local_script, remote_script))
                        fscript = open(local_script)
                        lines = fscript.readlines()
                        fscript.close()
                        cin.write('SCRIPT: %s: %d\n' % (local_script, len(lines)))
                        cin.writelines(lines)
                    else:
                        error('Illegal script specified: %s: %s' % (local_script, ret))
                        cin.write('SCRIPT: %s: %d\n' % ('-', 0))
                else:
                    warn('No server based script found: %s' % local_script)
                    cin.write('SCRIPT: %s: %d\n' % ('-', 0))
                cin.flush()
                setup_errs += (do_server_getanswer(cout, cl_status))[0]
            if cleanup:
                cleaned_up = False
                cin.write('SCRUB\n')
                cin.flush()
                setup_errs += (do_server_getanswer(cout, cl_status))[0]
                bdir = '/'  # Fake directory for the rest of the cleanup
                errs = 0
                stats['state'] = 'CLEAN'
                # run a server-side hook following client SCRUB
                if cfg['script'] and cfg['run_on'] == 'server':
                    setup_errs += server_side_script('POST-SCRUB', cfg, datadir)
            else:
                # run a server-side hook prior to client SETUP
                if cfg['script'] and cfg['run_on'] == 'server':
                    setup_errs += server_side_script('PRE-SETUP', cfg, datadir)

                cin.write('SETUP\n')
                cin.flush()
                errs, bdir = do_server_getanswer(cout, cl_status)
                setup_errs += errs

                # run a server-side hook following client SETUP
                if cfg['script'] and cfg['run_on'] == 'server':
                    setup_errs += server_side_script('POST-SETUP', cfg, datadir)

                if os.path.isdir(rdiff_logdir):
                    rdiff_logpre = os.listdir(rdiff_logdir)
                else:
                    rdiff_logpre = []

                if fbm:
                    backup_marker = '=== Backup session on %s ===' % time.asctime()
                    fbm.write(backup_marker + '\n')
                else:
                    backup_marker = None

                cleaned_up = False
                do_server_rdiff(cfg, bdir, nice, ionice, force)
                cleaned_up = True

                errs = 0
                if os.path.isdir(rdiff_logdir):
                    info_file(backup_log, backup_marker, None)
                    rdiff_logpost = os.listdir(rdiff_logdir)
                    for lfn in rdiff_logpost:
                        if lfn.startswith('session_statistics.') and lfn.endswith('.data') and lfn not in rdiff_logpre:
                            errs += info_file(os.path.join(rdiff_logdir, lfn), None, stats)
                            stats['state'] = 'OK'
                            stats['Errors'] = str(errs + setup_errs)
                else:
                    warn('Log dir does not exist.')
                    warning_counter += 1
                    stats['state'] = 'MISSING'

            cin.write('CLEANUP %s\n' % bdir)
            cin.flush()
            setup_errs += (do_server_getanswer(cout, cl_status))[0]

            # run a server-side hook as the last thing
            if cfg['script'] and cfg['run_on'] == 'server':
                setup_errs += server_side_script('POST-BACKUP', cfg, datadir)

            if (errs + setup_errs) == 0:
                info('Server backup for client %s: OK' % cfg_id)
            elif setup_errs == 0:
                warning_counter += 1
                stats['state'] = 'WARNING'
                info('Server backup for client %s: OK (%d WARNINGS)' % (cfg_id, errs))
            else:
                warning_counter += 1
                stats['state'] = 'SETUP WARNING'
                info('Server backup for client %s: OK (%d WARNINGS/%d SETUP WARNINGS)' % (cfg_id, errs, setup_errs))

        except Exception as ex:
            if cleanup:
                info('Client-side cleanup for client %s: FAILED' % cfg_id)
                if isinstance(ex, ClientException):
                    if ex.traceback and verbosity_level > 2:
                        info(ex.traceback)
                    else:
                        info('Reason: %s' % (ex or ''))
            else:
                if isinstance(ex, ClientException):
                    error('Client %s: FAILED due to: %s' % (cfg_id, ex or ''))
                    if ex.traceback and verbosity_level > 2: error(ex.traceback)
                else:
                    error('Server backup for client %s: FAILED' % cfg_id, ex)
            error_counter += 1
            stats['state'] = 'FAILED'

        statistics.append(stats)

        # Shutdown client
        if fbm: fbm.close()
        if cout: cout.close()
        if cin: cin.close()

        if not cleaned_up:
            do_server_rdiff_cleanup(cfg)
            cleaned_up = True

    if output_done:
        info('------------------------------------------------------------------')
    debug('Server backup done')

def do_list(cfgs, ids, list_type, list_date, list_parsable):
    global statistics, error_counter
    debug("Do server listing main loop")
    output_done = False
    for cfg in sorted(iter(cfgs.values()), key=lambda cfg: cfg['id']):
        cfg_id = cfg['id']
        if ids and cfg_id not in ids: continue
        stats = {}
        if list_parsable:
            info('Client: %s' % cfg_id)
        else:
            info('------------------------------------------------------------------')
            info('Server listing for client %s' % cfg_id)
        stats['id'] = cfg_id
        output_done = True

        args = [executable_lookup(cfg, 'rdiff-backup', 'local')]

        if list_type == 'increments':
            args.extend(['--list-increments'])
        elif list_type == 'sizes':
            args.extend(['--list-increment-sizes'])
        elif list_type == 'changed':
            args.extend(['--list-changed-since', list_date])
        elif list_type == 'attime':
            args.extend(['--list-at-time', list_date])
        else:
            assert False, 'Unknown list type: ' + list_type

        if list_parsable:
            args.extend(['--parsable-output'])

        args.extend([cfg['dir']])
        # Call a low level routine to get the data back as well.
        ret, lines = _spawn(args)
        if ret:
            error('Failed to run %s' % executable_lookup(cfg, 'rdiff-backup', 'local'))
            error_counter += 1
            stats['state'] = 'FAILED'
        else:
            stats['state'] = 'OK'
            if list_type == 'increments':
                if list_parsable:
                    stats['Increments'] = str(len(lines) - 1)
                    date_time = lines[len(lines) - 1].split(None, 1)[0]
                    stats['CurrentMirror'] = time.ctime(int(date_time))
                    date_time = lines[0].split(None, 1)[0]
                    stats['OldestIncrement'] = time.ctime(int(date_time))
                else:
                    increments = len(lines) - 2
                    stats['Increments'] = str(increments)
                    stats['CurrentMirror'] = lines[len(lines) - 1].strip().split(None, 2)[2]
                    if increments == 0:
                        stats['OldestIncrement'] = lines[1].strip().split(None, 2)[2]
                    else:
                        stats['OldestIncrement'] = lines[1].strip().split(None, 1)[1]
            elif list_type == 'sizes':
                stats['Increments'] = str(len(lines) - 3)
                stats['CurrentMirror'] = lines[2].split('   ', 1)[0]
                stats['OldestIncrement'] = lines[len(lines) - 1].split('   ', 1)[0]

        statistics.append(stats)

    if output_done and not list_parsable:
        info('------------------------------------------------------------------')
    debug('Server listing done')

def do_keys(cfgs, ids, nice_rem, identity, status, dump, deploy):
    for cfg in sorted(iter(cfgs.values()), key=lambda cfg: cfg['id']):
        cfg_id = cfg['id']
        if ids and cfg_id not in ids: continue
        info('Handling keys for client: %s' % cfg_id)
        if not cfg['host']:
            info('%s: Client is local, it needs no keys' % cfg_id)
            continue

        nice = cfg['nice'] or nice_rem
        if nice:
            nice_cmd = 'nice -n%s ' % (nice)
        else:
            nice_cmd = ''

        cmds = ['safekeep --client', '%s --server --restrict-read-only /' % executable_lookup(cfg, 'rdiff-backup', 'remote')]
        privatekeyfiles = [cfg.get('key_ctrl'), cfg.get('key_data')]
        output = []
        keys_ok = False
        for (cmd, privatekeyfile) in zip(cmds, privatekeyfiles):
            publickeyfile = privatekeyfile + '.pub'
            if not os.path.isfile(privatekeyfile):
                if os.path.isfile(publickeyfile):
                    error('%s: Public key exists %s, but private key is missing. Skipping client.' % (cfg_id, publickeyfile))
                    break
                if dump:
                    print('%s: Key does not exist: %s.' % (cfg_id, privatekeyfile))
                    break
                if status:
                    print('%s: Key does not exist: %s. Will be generated.' % (cfg_id, privatekeyfile))
                    break
                if deploy:
                    info('%s: Key do not exist, generating it now: %s' % (cfg_id, privatekeyfile))
                    if ssh_keygen_bits:
                        keygen_bits = '-b %d' % ssh_keygen_bits
                    else:
                        keygen_bits = ''
                    gencmd = 'ssh-keygen -q %s -t %s -N "" -C "SafeKeep auto generated key at %s@%s" -f %s' % (keygen_bits, ssh_keygen_type, backup_user, os.uname()[1], privatekeyfile)
                    if backup_user != work_user:
                        gencmd = 'su -s /bin/sh -c %s - %s' % (shlex.quote(gencmd), backup_user)
                    debug(gencmd)
                    if spawn(gencmd):
                        error('%s: Failed to generate key %s. Skipping client.' % (cfg_id, privatekeyfile))
                        break
            if not os.path.isfile(publickeyfile):
                error('%s: Private key exists %s, but public key is missing. Skipping client.' % (cfg_id, privatekeyfile))
                break
            fin = open(publickeyfile, 'r')
            publickey = fin.read()
            fin.close()
            line = 'command="%s%s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s' % (nice_cmd, cmd, publickey.strip())
            output.append(line)
        else:
            keys_ok = True

        if not keys_ok:
            continue

        if dump:
            print(output)

        basessh = ['ssh', '-oStrictHostKeyChecking=%s' % (ssh_StrictHostKeyChecking) ]
        if cfg['port']: basessh.append('-p %s' % cfg['port'])
        if identity: basessh.append('-i %s' % (shlex.quote(identity)))

        if status or deploy:
            cmd = basessh + ['%s@%s' % (cfg['user'], cfg['host']), "if test -f .ssh/authorized_keys; then cat .ssh/authorized_keys; fi"]
            authtext = call(cmd)
            if authtext is None:
                error('%s: Failed to read the %s@%s:~/.ssh/authorized_keys file.' % (cfg_id, cfg['user'], cfg['host']))
                continue
            auth_keys = parse_authorized_keys(authtext)
            this_keys = parse_authorized_keys(output)
            new_keys = []
            for this_key in this_keys:
                for auth_key in auth_keys:
                    if this_key[2] == auth_key[2]: break
                else:
                    new_keys.append(this_key)
            if not new_keys:
                if status:
                    print('%s: Client is up to date.' % cfg_id)
                continue

            if status:
                print('%s: Keys will be deployed on the client.' % cfg_id)
            if deploy:
                cmd = basessh + ['%s@%s' % (cfg['user'], cfg['host']), "umask 077; test -d .ssh || mkdir .ssh; cat >> .ssh/authorized_keys"]
                keys = '%s\n' % '\n'.join([key[4] for key in new_keys])
                out = call(cmd, stdin=keys)
                if out is None:
                    error('Failed to deliver the keys to the client')

# parses authozied_keys, see sshd(8) man page for details
def parse_authorized_keys(keystext):
    keys = []
    for line in keystext:
        line = line.strip()
        if not line or line[0] == '#': continue
        if line[0] in '0123456789':
            warn('SSH Protocol 1 keys are ignored: %s' % line)
            continue
        opts = ''
        if line.split(None, 1)[0] not in SSH_KEY_TYPES:
            in_str = False
            in_esc = False
            for i, c in enumerate(line):
                if in_str:
                    if in_esc: in_esc = False
                    elif c == '\'': in_esc = True
                    elif c == '"': in_str = False
                else:
                    if c == ' ':
                        rest = line[i:].strip()
                        break
                    elif c == '"': in_str = True
                opts += c
            else:
                info('Invalid key line, ignoring: %s' % line)
                continue
        else:
            rest = line

        if rest[0] in '0123456789':
            warn('SSH Protocol 1 keys are ignored: %s' % line)
            continue

        parts = rest.split(None, 2)
        if len(parts) < 2:
            error('Invalid key line, skipping: %s' % line)
            continue

        ssh_type = parts[0]
        if ssh_type not in SSH_KEY_TYPES:
            error('Invalid key type "%s", skipping: %s' % (ssh_type, line))
            continue

        base46enc = parts[1]

        if len(parts) == 2:
            comment = None
        else:
            comment = parts[2]

        keys.append((opts, ssh_type, base46enc, comment, line))

    return keys

######################################################################
# Main routine
######################################################################

def usage(exitcode=None):
    print('usage: %s --server [common options] [server options] <client-id>*' % (sys.argv[0]))
    print('       %s --keys [common options] [keys options] <client-id>*' % (sys.argv[0]))
    print('       %s --list [common options] [list options] <client-id>*' % (sys.argv[0]))
    print()
    print('mode selection (you must pick one):')
    print('--server            launch in server mode')
    print('--keys              launch in keys management mode')
    print('--list              list previous backup status')
    print()
    print('common options:')
    print('-c, --conf=FILE     use the FILE configuration file')
    print('-h, --help          show this help message and exit')
    print('-q, --quiet         decreases the verbosity level')
    print('-v, --verbose       increases the verbosity level')
    print('-V, --version       show the version number and exit')
    print('--noemail           disables the sending of email')
    print()
    print('server options:')
    print('--force             force backup destination overwriting, dangerous!')
    print('--cleanup           perform cleanup actions after a failure')
    print('-t, --tempdir=DIR   set tempdir to DIR for rdiff-backup')
    print()
    print('keys options:')
    print('-i FILE             use FILE as identity for RSA/DSA authentication')
    print('--status            display the key status for the clients (default)')
    print('--print             display the authorization keys')
    print('--deploy            deploy the authorization keys')
    print()
    print('list options:')
    print('--increments        list number and dates of increments')
    print('--parsable-output   tailor output for parsing by other programs')
    print('--sizes             list sizes of all the increments')
    print('--changed=time      list files that have changed since time')
    print('--at-time=time      list files in the archive at given time')
    if exitcode is not None: sys.exit(exitcode)

def main():
    try:
        opts, args = getopt.getopt(sys.argv[1:], 'c:e:i:hs:t:qvV',
                                   ['conf=', 'client', 'deploy',
                                    'email=', 'force', 'help', 'keys',
                                    'list', 'increments', 'sizes',
                                    'parsable-output', 'changed=', 'at-time=',
                                    'noemail', 'cleanup',
                                    'print', 'quiet', 'server', 'smtp=',
                                    'status', 'tempdir=', 'verbose', 'version'])
    except getopt.GetoptError:
        usage(2)

    global backup_user, backup_tempdir, client_user, home_dir, base_dir, config_file
    global verbosity_level

    mode = None
    email = {}
    cfgfile = None
    cfglocs = []
    verbosity = 0
    force = False
    cleanup = False
    noemail = False
    list_type = None
    list_parsable = False
    list_date = None
    identity = None
    keys_status = False
    keys_print = False
    keys_deploy = False
    nice_srv = None

    if os.getuid():
        user_path = os.path.expanduser('~/.safekeep')
        if os.path.exists(user_path) and os.path.isdir(user_path):
            config_file = user_path + '/safekeep.conf'

    for o, a in opts:
        if o in ('-c', '--conf'):
            if os.path.isdir(a) or a.endswith(config_ext):
                warn('Adding client config files/dirs via this switch is deprecated')
                cfglocs.append(a)
            elif cfgfile is None:
                cfgfile = a
            else:
                error('A main configuration file can be specified only once!')
                sys.exit(2)
        elif o in ('-e', '--email'):
            warn('The -e/--email options are deprecated and will be removed in the future')
            warn('Please use the %s instead' % (config_file))
            if 'to' in email:
                email['to'].append(a)
            else:
                email['to'] = a
        elif o in ('-h', '--help'):
            usage(0)
        elif o in ('-s', '--smtp'):
            warn('The -s/--smtp options are deprecated and will be removed in the future')
            warn('Please use the %s instead' % (config_file))
            email['smtp'] = a
        elif o in ('--server', ):
            if mode: usage(2)
            mode = 'server'
        elif o in ('--list', ):
            if mode: usage(2)
            mode = 'list'
        elif o in ('--client', ):
            if mode: usage(2)
            mode = 'client'
        elif o in ('--keys', ):
            if mode: usage(2)
            mode = 'keys'
        elif o in ('--force', ):
            force = True
        elif o in ('--cleanup', ):
            cleanup = True
        elif o in ('--noemail', ):
            noemail = True
        elif o in ('-t', '--tempdir'):
            backup_tempdir = a
        elif o in ('--increments', ):
            if list_type: usage(2)
            list_type = 'increments'
        elif o in ('--sizes', ):
            if list_type: usage(2)
            list_type = 'sizes'
        elif o in ('--parsable-output', ):
            list_parsable = True
        elif o in ('--changed', ):
            if list_type: usage(2)
            list_type = 'changed'
            list_date = a
        elif o in ('--at-time', ):
            if list_type: usage(2)
            list_type = 'attime'
            list_date = a
        elif o in ('-i', ):
            identity = a
        elif o in ('--status', ):
            keys_status = True
        elif o in ('--print', ):
            keys_print = True
        elif o in ('--deploy', ):
            keys_deploy = True
        elif o in ('-q', '--quiet'):
            verbosity -= 1
        elif o in ('-v', '--verbose'):
            verbosity += 1
        elif o in ('-V', '--version'):
            print('safekeep', VERSION)
            return

    if mode is None:
        usage(2)

    if mode != 'keys' and (identity or keys_status or keys_print or keys_deploy):
        usage(2)

    if mode != 'list' and (list_type or list_date or list_parsable):
        usage(2)

    if mode != 'server' and ('to' in email or 'smtp' in email):
        usage(2)

    if not mode in ('server', 'client') and cleanup:
        usage(2)

    if mode == 'client' and cfglocs:
        usage(2)

    if mode != 'client':
        if cfgfile is None and os.path.isfile(config_file):
            cfgfile = config_file
        if cfgfile and os.path.isfile(cfgfile):
            props = parse_prop_file(cfgfile)
        else:
            if cfgfile:
                warn('Configuration file does not exist, skipping: %s' % cfgfile)
            else:
                cfgfile = config_file
            props = {}

        def get_int(p):
            v = props.get(p)
            if v is not None and v != '':
                return int(v)
            return None

        if 'backup.user' in props:
            backup_user = props['backup.user']
        if 'backup.tempdir' in props and not backup_tempdir:
            backup_tempdir = props['backup.tempdir']
        if 'base.dir' in props:
            base_dir = props['base.dir']
        if 'client.user' in props:
            client_user = props['client.user']
        if not noemail:
            for prop in [prop for prop in props if prop.startswith('email.')]:
                if prop == 'email.to':
                    email['to'] = props[prop].split(',')
                elif prop == 'email.format':
                    email['format'] = props[prop]
                    if email['format'] not in ('text', 'html'):
                        error('CONFIG ERROR: invalid email format type: %s' % email['format'])
                        sys.exit(2)
                elif prop == 'email.summary':
                    if props[prop].lower() in ('true', 'yes', '1'):
                        email['summary'] = props[prop]
                else:
                    proplist = prop.split('.')
                    if len(proplist) > 2:
                        if proplist[1] not in email: email[proplist[1]] = {}
                        if prop == 'email.smtp.port':
                            val = get_int(prop)
                            if not val or val <= 0:
                                error('CONFIG ERROR: invalid email.smtp.port value: %s: must be a postive numeric value' % props[prop])
                            email[proplist[1]][proplist[2]] = val
                        else:
                            email[proplist[1]][proplist[2]] = props[prop]
                    else:
                        email[proplist[1]] = props[prop]
        else:
            email = {}
        nice_def = get_int('nice.adjustment')
        if nice_def is None: nice_def = 10
        nice_srv = get_int('nice.adjustment.server') or nice_def
        nice_cln = get_int('nice.adjustment.client') or nice_def
        ionice_def = props.get('ionice.adjustment')
        if ionice_def is None: ionice_def = 'idle'
        if ionice_def == '': ionice_def = 'none'

        global default_bandwidth
        default_bandwidth['overall'] = get_int('bandwidth.limit') or 0
        default_bandwidth['download'] = get_int('bandwidth.limit.download') or 0
        default_bandwidth['upload'] = get_int('bandwidth.limit.upload') or 0

        global default_snapshot, client_defaults
        default_snapshot = props.get('snapshot.size')
        if default_snapshot:
            if default_snapshot.endswith('%'):
                default_snapshot += 'FREE'
            client_defaults.append('snapshot.size=%s' % default_snapshot)

        global ssh_keygen_type, ssh_keygen_bits, ssh_StrictHostKeyChecking
        if 'ssh.keygen.type' in props:
            ssh_keygen_type = props['ssh.keygen.type']
            if ssh_keygen_type not in SSH_TYPES:
                error('CONFIG ERROR: invalid ssh.keygen.type: %s' % props['ssh.keygen.type'])
                sys.exit(2)
        if 'ssh.keygen.bits' in props:
            if props['ssh.keygen.bits']:
                try:
                    ssh_keygen_bits = get_int('ssh.keygen.bits')
                except:
                    ssh_keygen_bits = -1
                if ssh_keygen_bits <= 0:
                    error('CONFIG ERROR: invalid ssh.keygen.bit value: %s' % props['ssh.keygen.bits'])
                    sys.exit(2)
            else:
                # For cases where no bit size is required
                ssh_keygen_bits = 0
        if 'ssh.strict_hostkey_checking' in props:
            ssh_StrictHostKeyChecking = props['ssh.strict_hostkey_checking']
            if ssh_StrictHostKeyChecking not in SSH_STRICT_HOSTKEY_CHECK_OPTS:
                error('CONFIG ERROR: invalid ssh.strict_hostkey_checking value: %s' % props['ssh.strict_hostkey_checking'])
                sys.exit(2)

        if len(cfglocs) == 0:
            locs = os.path.join(os.path.dirname(cfgfile), 'backup.d')
            if os.path.isdir(locs): cfglocs.append(locs)

    if backup_user and backup_user != work_user:
        (user, pswd, uid, gid, gecos, home_dir, shell) = pwd.getpwnam(backup_user)
        if mode != 'keys':
            try:
                os.setregid(gid, gid)
                os.setreuid(uid, uid)
            except OSError as ex:
                warn("Cannot setreuid(): " + str(ex))
            os.environ['HOME'] = home_dir
    else:
        backup_user = work_user
        home_dir = os.getenv('HOME', '/')

    if not base_dir:
        base_dir = home_dir

    if len(cfglocs) > 0:
        try:
            verbosity_level = 1 + verbosity
            cfgs = parse_locs(cfglocs)
        except Exception as ex:
            if isinstance(ex, ConfigException):
                error('CONFIG ERROR: %s' % (ex or ''), ex)
            else:
                error('ERROR: %s' % (ex or ''), ex)
            send_notification(email, mode)
            sys.exit(2)
    else:
        cfgs = {}

    if mode == 'client':
        if len(args) > 0: usage(2)
    else:
        ok = True
        for arg in args:
            if arg in cfgs: continue
            error('Unknown client ID: %s' % arg)
            if os.path.isfile(arg):
                error('It appears to be a file, configuration files are passed via the -c/--conf switch.')
            ok = False
        if not ok:
            send_notification(email, mode)
            sys.exit(2)

    try:
        global is_client, verbosity_ssh, verbosity_trickle

        if verbosity > 2:
            verbosity_trickle = verbosity_ssh = '-' + (verbosity-2) * 'v'
        if mode == 'server':

            if backup_tempdir:
                if not os.path.isabs(backup_tempdir):
                    info('backup.tempdir %s is relative to base.dir %s' % (backup_tempdir, base_dir))
                    backup_tempdir = os.path.join(base_dir, backup_tempdir)

                if not os.path.isdir(backup_tempdir):
                    error('CONFIG ERROR: backup.tempdir %s is not a valid directory' % backup_tempdir)
                    sys.exit(2)
                elif not os.access(backup_tempdir, os.W_OK):
                    error('CONFIG ERROR: backup.tempdir %s is not writeable' % backup_tempdir)
                    sys.exit(2)

            is_client = False
            verbosity_level = 1 + verbosity
            do_server(cfgs, args, nice_srv, ionice_def, force, cleanup)
        elif mode == 'list':
            if list_type is None:
                list_type = 'increments'
            is_client = False
            verbosity_level = 2 + verbosity
            do_list(cfgs, args, list_type, list_date, list_parsable)
        elif mode == 'client':
            if cleanup:
                is_client = False
                verbosity_level = 1 + verbosity
                do_client_scrub()
            else:
                is_client = True
                verbosity_level = 3 + verbosity
                do_client()
        elif mode == 'keys':
            is_client = False
            verbosity_level = 1 + verbosity
            if not keys_status and not keys_print and not keys_deploy:
                keys_status = True
            do_keys(cfgs, args, nice_cln, identity, keys_status, keys_print, keys_deploy)
        else:
            assert False, 'Unknown mode: %s' % (mode)
    except Exception as ex:
        error('ERROR: %s' % (ex or ''), ex)

    send_notification(email, mode)

if __name__ == '__main__':
    main()

# vim: et ts=8 sw=4 sts=4
