#! /usr/bin/env python

"""A front-end interface to the pyzor daemon."""

import os
import sys
import logging
import optparse
import collections
import ConfigParser

import pyzor
import pyzor.server
import pyzor.server_engines

def load_access_file(access_fn, accounts):
    """Load the ACL from the specified file, if it exists, and return an
    ACL dictionary, where each key is a username and each value is a set
    of allowed permissions (if the permission is not in the set, then it
    is not allowed).

    'accounts' is a dictionary of accounts that exist on the server - only
    the keys are used, which must be the usernames (these are the users
    that are granted permission when the 'all' keyword is used, as
    described below).

    Each line of the file should be in the following format:
        operation : user : allow|deny
    where 'operation' is a space-separated list of pyzor commands or the
    keyword 'all' (meaning all commands), 'username' is a space-separated
    list of usernames or the keyword 'all' (meaning all users) - the
    anonymous user is called "anonymous", and "allow|deny" indicates whether
    or not the specified user(s) may execute the specified operations.

    The file is processed from top to bottom, with the final match for
    user/operation being the value taken.  Every file has the following
    implicit final rule:
        all : all : deny

    If the file does not exist, then the following default is used:
        check report ping info : anonymous : allow
    """
    log = logging.getLogger("pyzord")
    # A defaultdict is safe, because if we get a non-existant user, we get
    # the empty set, which is the same as a deny, which is the final
    # implicit rule.
    acl = collections.defaultdict(set)
    if not os.path.exists(access_fn):
        log.info("Using default ACL: the anonymous user may use the check, "
                 "report, ping and info commands.")
        acl[pyzor.anonymous_user] = set(("check", "report", "ping", "pong", 
                                         "info"))
        return acl
    for line in open(access_fn):
        if not line.strip() or line[0] == "#":
            continue
        try:
            operations, users, allowed = [part.lower().strip()
                                          for part in line.split(":")]
        except ValueError:
            log.warn("Invalid ACL line: %r" % line)
            continue
        try:
            allowed = {"allow": True, "deny" : False}[allowed]
        except KeyError:
            log.warn("Invalid ACL line: %r" % line)
            continue
        if operations == "all":
            operations = ("check", "report", "ping", "pong", "info",
                          "whitelist")
        else:
            operations = [operation.strip()
                          for operation in operations.split()]
        if users == "all":
            users = accounts
        else:
            users = [user.strip() for user in users.split()]
        for user in users:
            if allowed:
                log.debug("Granting %s to %s." %
                          (",".join(operations), user))
                # If these operations are already allowed, this will have
                # no effect.
                acl[user].update(operations)
            else:
                log.debug("Revoking %s from %s." %
                          (",".join(operations), user))
                # If these operations are not allowed yet, this will have
                # no effect.
                acl[user].difference_update(operations)
    log.info("ACL: %r" % acl)
    return acl

def load_passwd_file(passwd_fn):
    """Load the accounts from the specified file.

    Each line of the file should be in the format:
        username : key

    If the file does not exist, then an empty dictionary is returned;
    otherwise, a dictionary of (username, key) items is returned.
    """
    log = logging.getLogger("pyzord")
    accounts = {}
    if not os.path.exists(passwd_fn):
        log.info("Accounts file does not exist - only the anonymous user "
                 "will be available.")
        return accounts
    for line in open(passwd_fn):
        if not line.strip() or line[0] == "#":
            continue
        try:
            user, key = line.split(":")
        except ValueError:
            log.warn("Invalid accounts line: %r" % line)
            continue
        user = user.strip()
        key = key.strip()
        log.debug("Creating an account for %s with key %s." % (user, key))
        accounts[user] = key
    # Don't log the keys at 'info' level, just ther usernames.
    log.info("Accounts: %s" % ",".join(accounts))
    return accounts

def load_configuration():
    """Load the configuration for the server.

    The configuration comes from three sources: the default values, the
    configuration file, and command-line options."""
    # Work out the default directory for configuration files.
    # If $HOME is defined, then use $HOME/.pyzor, otherwise use /etc/pyzor.
    userhome = os.getenv("HOME")
    if userhome:
        homedir = os.path.join(userhome, '.pyzor')
    else:
        homedir = os.path.join("/etc", "pyzor")

    # Configuration defaults.  The configuration file overrides these, and
    # then the command-line options override those.
    defaults = {
        "Port" : "24441",
        "ListenAddress" : "0.0.0.0",
        "Engine" : "gdbm",
        "DigestDB" : "pyzord.db",
        "Threads": "False",
        "MaxThreads": "0",
        "Processes": "False",
        "MaxProcesses": "40",
        "DBConnections": "0",
        "PasswdFile" : "pyzord.passwd",
        "AccessFile" : "pyzord.access",
        "CleanupAge" : str(60 * 60 * 24 * 30 * 4),  # approximately 4 months
        "LogFile" : "pyzord.log",
    }

    # Process any command line options.
    description = "Listen for and process incoming Pyzor connections."
    opt = optparse.OptionParser(description=description)
    opt.add_option("-n", "--nice", dest="nice", type="int",
                   help="'nice' level", default=0)
    opt.add_option("-d", "--debug", action="store_true", default=False,
                   dest="debug", help="enable debugging output")
    opt.add_option("--homedir", action="store", default=homedir,
                   dest="homedir", help="configuration directory")
    opt.add_option("-a", "--address", action="store", default=None,
                   dest="ListenAddress", help="listen on this IP")
    opt.add_option("-p", "--port", action="store", type="int", default=None,
                   dest="Port", help="listen on this port")
    opt.add_option("-e", "--database-engine", action="store", default=None,
                   dest="Engine", help="select database backend")
    opt.add_option("--dsn", action="store", default=None, dest="DigestDB",
                   help="data source name (filename for gdbm, host,user,"
                   "password,database,table for MySQL)")
    opt.add_option("--threads", action="store", default=None, dest="Threads",
                   help="set to true if multi-threading should be used"
                   " (this may not apply to all engines)")
    opt.add_option("--max-threads", action="store", default=None, type="int",
                   dest="MaxThreads", help="the maximum number of concurrent "
                   "threads (defaults to 0 which is unlimited)")
    opt.add_option("--processes", action="store", default=None,
                   dest="Processes", help="set to true if multi-processing "
                   "should be used (this may not apply to all engines)")
    opt.add_option("--max-processes", action="store", default=None, type="int",
                   dest="MaxProcesses", help="the maximum number of concurrent "
                   "processes (defaults to 40)")
    opt.add_option("--db-connections", action="store", default=None, type="int",
                   dest="DBConnections", help="the number of db connections "
                   "that will be kept by the server. This only applies if "
                   "threads are used. Defaults to 0 which means a new "
                   "connection is used for every thread. (this may not apply "
                   "all engines)")
    opt.add_option("--password-file", action="store", default=None,
                   dest="PasswdFile", help="name of password file")
    opt.add_option("--access-file", action="store", default=None,
                   dest="AccessFile", help="name of ACL file")
    opt.add_option("--cleanup-age", action="store", default=None,
                   dest="CleanupAge",
                   help="time before digests expire (in seconds)")
    opt.add_option("--log-file", action="store", default=None,
                   dest="LogFile", help="name of log file")
    opt.add_option("-V", "--version", action="store_true", default=False,
                   dest="version", help="print version and exit")
    options, args = opt.parse_args()

    if options.version:
        print "%s %s" % (sys.argv[0], pyzor.__version__)
        sys.exit(0)

    if len(args):
        opt.print_help()
        sys.exit()
    os.nice(options.nice)

    # Create the configuration directory if it doesn't already exist.
    if not os.path.exists(homedir):
        os.mkdir(homedir)

    # Load the configuration.
    config = ConfigParser.ConfigParser()
    # Set the defaults.
    config.add_section("server")
    for key, value in defaults.iteritems():
        config.set("server", key, value)
    # Override with the configuration.
    config.read(os.path.join(options.homedir, "config"))
    # Override with the command-line options.
    for key in defaults:
        value = getattr(options, key)
        if value is not None:
            config.set("server", key, str(value))
    return config, options

def main():
    """Run the pyzor daemon."""
    # Set umask - this restricts this process from granting any world access
    # to files/directories created by this process.
    os.umask(0077)

    config, options = load_configuration()

    # Setup logging.
    log_fn = config.get("server", "LogFile")
    if not log_fn:
        # If no log file is specified, then output to stdout.
        handler = logging.StreamHandler()
    else:
        log_fn = os.path.expanduser(log_fn)
        if not os.path.isabs(log_fn):
            log_fn = os.path.join(options.homedir, log_fn)
        handler = logging.FileHandler(log_fn)
    if options.debug:
        log_level = logging.DEBUG
    else:
        log_level = logging.INFO
    # There are two logs - one only contains usage data - but we combine
    # them into a single output.
    handler.setLevel(log_level)
    handler.setFormatter(
        logging.Formatter('%(asctime)s %(levelname)s %(message)s'))
    logger = logging.getLogger("pyzord")
    logger.setLevel(log_level)
    logger.addHandler(handler)
    logger = logging.getLogger("pyzord-usage")
    logger.setLevel(log_level)
    logger.addHandler(handler)

    # Load accounts and ACL.
    passwd_fn = os.path.expanduser(config.get("server", "PasswdFile"))
    if not os.path.isabs(passwd_fn):
        passwd_fn = os.path.join(options.homedir, passwd_fn)
    accounts = load_passwd_file(passwd_fn)
    access_fn = os.path.expanduser(config.get("server", "AccessFile"))
    if not os.path.isabs(access_fn):
        access_fn = os.path.join(options.homedir, access_fn)
    acl = load_access_file(access_fn, accounts)
    address = (config.get("server", "ListenAddress"),
               int(config.get("server", "port")))

    engine = config.get("server", "Engine")
    # Open the database connection.
    database_classes = pyzor.server_engines.database_classes[engine]
    use_threads = config.get("server", "Threads").lower() == "true"
    use_processes = config.get("server", "Processes").lower() == "true"

    if use_threads and use_processes:
        print "You cannot use both processes and threads at the same time"
        sys.exit(1)

    # We prefer to use the threaded server, but some database engines
    # cannot handle it.
    if use_threads and database_classes.multi_threaded:
        database_class = database_classes.multi_threaded
    elif use_processes and database_classes.multi_processing:
        database_class = database_classes.multi_processing
    else:
        use_threads = False
        use_processes = False
        database_class = database_classes.single_threaded

    # If the DSN is a filename, then we make it absolute.
    db_file = config.get("server", "DigestDB")
    if database_class.absolute_source:
        db_file = os.path.expanduser(db_file)
        if not os.path.isabs(db_file):
            db_file = os.path.join(options.homedir, db_file)

    cleanup_age = int(config.get("server", "CleanupAge"))

    if use_threads:
        max_threads = int(config.get("server", "MaxThreads"))
        bound = int(config.get("server", "DBConnections"))

        database = database_class(db_file, "c", cleanup_age, bound)
        if max_threads == 0:
            logger.info("Starting multi-threaded pyzord server.")
            server = pyzor.server.ThreadingServer(address, database, accounts,
                                                  acl)
        else:
            logger.info("Starting bounded (%s) multi-threaded pyzord server.",
                        max_threads)
            server = pyzor.server.BoundedThreadingServer(address, database,
                                                         accounts, acl,
                                                         max_threads)
    elif use_processes:
        max_children = int(config.get("server", "MaxProcesses"))
        database = database_class(db_file, "c", cleanup_age)
        logger.info("Starting bounded (%s) multi-processing pyzord server.",
                    max_children)
        server = pyzor.server.ProcessServer(address, database, accounts, acl,
                                            max_children)
    else:
        database = database_class(db_file, "c", cleanup_age)
        logger.info("Starting pyzord server.")
        server = pyzor.server.Server(address, database, accounts, acl)
    # Start listening.
    server.serve_forever()

if __name__ == "__main__":
    main()
