1"""Functions that handle parsing pyzor configuration files."""
2
3import os
4import re
5import logging
6import collections
7
8try:
9    from raven.handlers.logging import SentryHandler
10    _has_raven = True
11except ImportError:
12    _has_raven = False
13
14import pyzor.account
15
16_COMMENT_P = re.compile(r"((?<=[^\\])#.*)")
17
18
19# Configuration files for the Pyzor Server
20def load_access_file(access_fn, accounts):
21    """Load the ACL from the specified file, if it exists, and return an
22    ACL dictionary, where each key is a username and each value is a set
23    of allowed permissions (if the permission is not in the set, then it
24    is not allowed).
25
26    'accounts' is a dictionary of accounts that exist on the server - only
27    the keys are used, which must be the usernames (these are the users
28    that are granted permission when the 'all' keyword is used, as
29    described below).
30
31    Each line of the file should be in the following format:
32        operation : user : allow|deny
33    where 'operation' is a space-separated list of pyzor commands or the
34    keyword 'all' (meaning all commands), 'username' is a space-separated
35    list of usernames or the keyword 'all' (meaning all users) - the
36    anonymous user is called "anonymous", and "allow|deny" indicates whether
37    or not the specified user(s) may execute the specified operations.
38
39    The file is processed from top to bottom, with the final match for
40    user/operation being the value taken.  Every file has the following
41    implicit final rule:
42        all : all : deny
43
44    If the file does not exist, then the following default is used:
45        check report ping info : anonymous : allow
46    """
47    log = logging.getLogger("pyzord")
48    # A defaultdict is safe, because if we get a non-existant user, we get
49    # the empty set, which is the same as a deny, which is the final
50    # implicit rule.
51    acl = collections.defaultdict(set)
52    if not os.path.exists(access_fn):
53        log.info("Using default ACL: the anonymous user may use the check, "
54                 "report, ping and info commands.")
55        acl[pyzor.anonymous_user] = set(("check", "report", "ping", "pong",
56                                         "info"))
57        return acl
58    accessf = open(access_fn)
59    for line in accessf:
60        if not line.strip() or line[0] == "#":
61            continue
62        try:
63            operations, users, allowed = [part.lower().strip()
64                                          for part in line.split(":")]
65        except ValueError:
66            log.warn("Invalid ACL line: %r", line)
67            continue
68        try:
69            allowed = {"allow": True, "deny": False}[allowed]
70        except KeyError:
71            log.warn("Invalid ACL line: %r", line)
72            continue
73        if operations == "all":
74            operations = ("check", "report", "ping", "pong", "info",
75                          "whitelist")
76        else:
77            operations = [operation.strip()
78                          for operation in operations.split()]
79        if users == "all":
80            users = accounts
81        else:
82            users = [user.strip() for user in users.split()]
83        for user in users:
84            if allowed:
85                log.debug("Granting %s to %s.", ",".join(operations), user)
86                # If these operations are already allowed, this will have
87                # no effect.
88                acl[user].update(operations)
89            else:
90                log.debug("Revoking %s from %s.", ",".join(operations), user)
91                # If these operations are not allowed yet, this will have
92                # no effect.
93                acl[user].difference_update(operations)
94    accessf.close()
95    log.info("ACL: %r", acl)
96    return acl
97
98
99def load_passwd_file(passwd_fn):
100    """Load the accounts from the specified file.
101
102    Each line of the file should be in the format:
103        username : key
104
105    If the file does not exist, then an empty dictionary is returned;
106    otherwise, a dictionary of (username, key) items is returned.
107    """
108    log = logging.getLogger("pyzord")
109    accounts = {}
110    if not os.path.exists(passwd_fn):
111        log.info("Accounts file does not exist - only the anonymous user "
112                 "will be available.")
113        return accounts
114    passwdf = open(passwd_fn)
115    for line in passwdf:
116        if not line.strip() or line[0] == "#":
117            continue
118        try:
119            user, key = line.split(":")
120        except ValueError:
121            log.warn("Invalid accounts line: %r", line)
122            continue
123        user = user.strip()
124        key = key.strip()
125        log.debug("Creating an account for %s with key %s.", user, key)
126        accounts[user] = key
127    passwdf.close()
128    # Don't log the keys at 'info' level, just ther usernames.
129    log.info("Accounts: %s", ",".join(accounts))
130    return accounts
131
132
133# Configuration files for the Pyzor Client
134def load_accounts(filepath):
135    """Layout of file is: host : port : username : salt,key"""
136    accounts = {}
137    log = logging.getLogger("pyzor")
138    if os.path.exists(filepath):
139        accountsf = open(filepath)
140        for lineno, orig_line in enumerate(accountsf):
141            line = orig_line.strip()
142            if not line or line.startswith('#'):
143                continue
144            try:
145                host, port, username, key = [x.strip()
146                                             for x in line.split(":")]
147            except ValueError:
148                log.warn("account file: invalid line %d: wrong number of "
149                         "parts", lineno)
150                continue
151            try:
152                port = int(port)
153            except ValueError as ex:
154                log.warn("account file: invalid line %d: %s", lineno, ex)
155                continue
156            address = (host, port)
157            try:
158                salt, key = pyzor.account.key_from_hexstr(key)
159            except ValueError as ex:
160                log.warn("account file: invalid line %d: %s", lineno, ex)
161                continue
162            if not salt and not key:
163                log.warn("account file: invalid line %d: keystuff can't be "
164                         "all None's", lineno)
165                continue
166            accounts[address] = pyzor.account.Account(username, salt, key)
167        accountsf.close()
168
169    else:
170        log.warn("No accounts are setup.  All commands will be executed by "
171                 "the anonymous user.")
172    return accounts
173
174
175def load_servers(filepath):
176    """Load the servers file."""
177    logger = logging.getLogger("pyzor")
178    if not os.path.exists(filepath):
179        servers = []
180    else:
181        servers = []
182        with open(filepath) as serverf:
183            for line in serverf:
184                line = line.strip()
185                if re.match("[^#][a-zA-Z0-9.-]+:[0-9]+", line):
186                    address, port = line.rsplit(":", 1)
187                    servers.append((address, int(port)))
188
189    if not servers:
190        logger.info("No servers specified, defaulting to public.pyzor.org.")
191        servers = [("public.pyzor.org", 24441)]
192    return servers
193
194
195def load_local_whitelist(filepath):
196    """Load the local digest skip file."""
197    if not os.path.exists(filepath):
198        return set()
199
200    whitelist = set()
201    with open(filepath) as serverf:
202        for line in serverf:
203            # Remove any comments
204            line = _COMMENT_P.sub("", line).strip()
205            if line:
206                whitelist.add(line)
207    return whitelist
208
209
210# Common configurations
211def setup_logging(log_name, filepath, debug, sentry_dsn=None,
212                  sentry_lvl="WARN"):
213    """Setup logging according to the specified options. Return the Logger
214    object.
215    """
216    fmt = logging.Formatter('%(asctime)s (%(process)d) %(levelname)s '
217                            '%(message)s')
218
219    stream_handler = logging.StreamHandler()
220
221    if debug:
222        stream_log_level = logging.DEBUG
223        file_log_level = logging.DEBUG
224    else:
225        stream_log_level = logging.CRITICAL
226        file_log_level = logging.INFO
227
228    logger = logging.getLogger(log_name)
229    logger.setLevel(file_log_level)
230
231    stream_handler.setLevel(stream_log_level)
232    stream_handler.setFormatter(fmt)
233    logger.addHandler(stream_handler)
234
235    if filepath:
236        file_handler = logging.FileHandler(filepath)
237        file_handler.setLevel(file_log_level)
238        file_handler.setFormatter(fmt)
239        logger.addHandler(file_handler)
240
241    if sentry_dsn and _has_raven:
242        sentry_level = getattr(logging, sentry_lvl)
243        sentry_handler = SentryHandler(sentry_dsn)
244        sentry_handler.setLevel(sentry_level)
245        logger.addHandler(sentry_handler)
246
247    return logger
248
249
250def expand_homefiles(homefiles, category, homedir, config):
251    """Set the full file path for these configuration files."""
252    for filename in homefiles:
253        filepath = config.get(category, filename)
254        if not filepath:
255            continue
256        filepath = os.path.expanduser(filepath)
257        if not os.path.isabs(filepath):
258            filepath = os.path.join(homedir, filepath)
259        config.set(category, filename, filepath)
260