diff --git a/bin/kolab_parse_telemetry.py b/bin/kolab_parse_telemetry.py index 4832f87..a02182a 100755 --- a/bin/kolab_parse_telemetry.py +++ b/bin/kolab_parse_telemetry.py @@ -1,92 +1,95 @@ #!/usr/bin/python # # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os import rfc822 import socket import sys import time from optparse import OptionParser -from ConfigParser import SafeConfigParser +try: + from ConfigParser import SafeConfigParser +except ImportError: + from configparser import SafeConfigParser import sqlalchemy from sqlalchemy import Boolean from sqlalchemy import Column from sqlalchemy import Date from sqlalchemy import DateTime from sqlalchemy import ForeignKey from sqlalchemy import Integer from sqlalchemy import MetaData from sqlalchemy import String from sqlalchemy import Table from sqlalchemy import Text from sqlalchemy import create_engine from sqlalchemy.orm import mapper try: from sqlalchemy.orm import relationship except: from sqlalchemy.orm import relation as relationship try: from sqlalchemy.orm import sessionmaker except: from sqlalchemy.orm import create_session from sqlalchemy.schema import Index from sqlalchemy.schema import UniqueConstraint sys.path.append('..') sys.path.append('../..') import pykolab from pykolab.auth import Auth from pykolab.constants import KOLAB_LIB_PATH from pykolab import telemetry from pykolab.translate import _ # TODO: Figure out how to make our logger do some syslogging as well. log = pykolab.getLogger('pykolab.parse_telemetry') # TODO: Removing the stdout handler would mean one can no longer test by # means of manual execution in debug mode. #log.remove_stdout_handler() conf = pykolab.getConf() conf.finalize_conf() auth = Auth() db = telemetry.init_db() while True: try: log_file = conf.cli_args.pop(0) except: # TODO: More verbose failing or parse all in /var/lib/imap/log/ or # options? break telemetry_log = telemetry.TelemetryLog(log_file) telemetry.expire_sessions() diff --git a/bin/kolab_smtp_access_policy.py b/bin/kolab_smtp_access_policy.py index 6c82165..3e11775 100755 --- a/bin/kolab_smtp_access_policy.py +++ b/bin/kolab_smtp_access_policy.py @@ -1,1727 +1,1730 @@ #!/usr/bin/python # # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import datetime import os import sqlalchemy import sys import time from optparse import OptionParser -from ConfigParser import SafeConfigParser - +try: + from ConfigParser import SafeConfigParser +except ImportError: + from configparser import SafeConfigParser + from sqlalchemy import Boolean from sqlalchemy import Column from sqlalchemy import Date from sqlalchemy import DateTime from sqlalchemy import Integer from sqlalchemy import MetaData from sqlalchemy import String from sqlalchemy import Table from sqlalchemy import Sequence from sqlalchemy import PickleType from sqlalchemy import create_engine from sqlalchemy.orm import mapper try: from sqlalchemy.orm import sessionmaker except: from sqlalchemy.orm import create_session from sqlalchemy.schema import Index from sqlalchemy.schema import UniqueConstraint import pykolab from pykolab import utils from pykolab.auth import Auth from pykolab.constants import * from pykolab.translate import _ cache = None sys.path = ['..', '.'] + sys.path # TODO: Figure out how to make our logger do some syslogging as well. log = pykolab.getLogger('pykolab.smtp_access_policy') # TODO: Removing the stdout handler would mean one can no longer test by # means of manual execution in debug mode. log.remove_stdout_handler() conf = pykolab.getConf() auth = None mydomains = None # # Caching routines using SQLAlchemy. # # If creating the cache fails, we continue without any caching, significantly # increasing the load on LDAP. # cache_expire = 86400 try: metadata = MetaData() except: cache = False session = None policy_result_table = Table( 'policy_result', metadata, Column('id', Integer, Sequence('seq_id_result'), primary_key=True), Column('key', String(16), nullable=False), Column('value', Boolean, nullable=False), Column('sender', String(254), nullable=True), Column('recipient', String(254), nullable=False), Column('sasl_username', String(64)), Column('sasl_sender', String(64)), Column('created', Integer, nullable=False), Column('data', PickleType, nullable=True), mysql_charset='utf8', ) Index( 'fsrss', policy_result_table.c.key, policy_result_table.c.sender, policy_result_table.c.recipient, policy_result_table.c.sasl_username, policy_result_table.c.sasl_sender, unique=True ) class PolicyResult(object): def __init__( self, key=None, value=None, sender=None, recipient=None, sasl_username=None, sasl_sender=None, data=None ): self.key = key self.value = value self.sender = sender self.sasl_username = sasl_username self.sasl_sender = sasl_sender self.recipient = recipient self.created = (int)(time.time()) self.data = data mapper(PolicyResult, policy_result_table) statistic_table = Table( 'statistic', metadata, Column('id', Integer, Sequence('seq_id_statistic'), primary_key=True), Column('sender', String(254), nullable=False), Column('recipient', String(254), nullable=False), Column('date', Date, nullable=False), Column('count', Integer, nullable=False), mysql_charset='utf8', ) Index( 'srd', statistic_table.c.sender, statistic_table.c.recipient, statistic_table.c.date, unique=True ) class Statistic(object): def __init__(self, sender, recipient, date=datetime.date.today(), count=0): self.sender = sender self.recipient = recipient self.date = date self.count = count mapper(Statistic, statistic_table) class PolicyRequest(object): email_address_keys = ['sender', 'recipient'] recipients = [] auth = None sasl_domain = None sasl_user = None sender_domain = None sender_user = None sasl_user_uses_alias = False sasl_user_is_delegate = False def __init__(self, policy_request={}): """ Creates a new policy request object. Pass it a policy_request dictionary as described in the Postfix documentation on: http://www.postfix.org/SMTPD_POLICY_README.html """ for key in policy_request: # Normalize email addresses (they may contain recipient delimiters) if key in self.email_address_keys: policy_request[key] = normalize_address(policy_request[key]) if not key == 'recipient': if policy_request[key] == '': setattr(self, key, None) else: setattr(self, key, policy_request[key]) else: if not policy_request['recipient'].strip() == '': self.recipients = list( set(self.recipients + [policy_request['recipient']]) ) def add_request(self, policy_request={}): """ Add subsequent policy requests to the existing policy request. All data in the request should be the same as the initial policy request, but for the recipient - with destination limits set over 1, Postfix may attempt to deliver messages to more then one recipient during a single delivery attempt, and during submission, the policy will receive one policy request per recipient. """ # Check instance. Not sure what to do if the instance is not the same. if hasattr(self, 'instance'): if not policy_request['instance'] == self.instance: # TODO: We need to empty our pockets pass log.debug( _("Adding policy request to instance %s") % (self.instance), level=8 ) # Normalize email addresses (they may contain recipient delimiters) if 'recipient' in policy_request: policy_request['recipient'] = normalize_address(policy_request['recipient']) if not policy_request['recipient'].strip() == '': self.recipients = list( set(self.recipients + [policy_request['recipient']]) ) def parse_ldap_dn(self, dn): """ See if parameter 'dn' is a basestring LDAP dn, and if so, return the results we can obtain from said DN. Return a list of relevant attribute values. If not a DN, return None. """ values = [] try: import ldap.dn ldap_dn = ldap.dn.explode_dn(dn) except ldap.DECODING_ERROR: # This is not a DN. return None if len(ldap_dn) > 0: search_attrs = conf.get_list('kolab_smtp_access_policy', 'address_search_attrs') rule_subject = self.auth.get_user_attributes( self.sasl_domain, {'dn': dn}, search_attrs + ['objectclass'] ) for search_attr in search_attrs: if search_attr in rule_subject: if isinstance(rule_subject[search_attr], basestring): values.append(rule_subject[search_attr]) else: values.extend(rule_subject[search_attr]) return values else: # ldap.dn.explode_dn didn't error out, but it also didn't split # the DN properly. return None def parse_ldap_uri(self, uri): values = [] parsed_uri = utils.parse_ldap_uri(uri) if parsed_uri is None: return None (_protocol, _server, _port, _base_dn, _attrs, _scope, _filter) = parsed_uri if len(_attrs) == 0: search_attrs = conf.get_list('kolab_smtp_access_policy', 'address_search_attrs') else: search_attrs = [_attrs] self.auth._auth._bind() _users = self.auth._auth._search( _base_dn, scope=LDAP_SCOPE[_scope], filterstr=_filter, attrlist=search_attrs + ['objectclass'], override_search="_regular_search" ) for _user in _users: for search_attr in search_attrs: values.extend(_user[1][search_attr]) return values def parse_policy(self, _subject, _object, policy): """ Parse policy to apply on _subject, for object _object. The policy is a list of rules. The _subject is a sender for kolabAllowSMTPRecipient checks, and a recipient for kolabAllowSMTPSender checks. The _object is a recipient for kolabAllowSMTPRecipient checks, and a sender for kolabAllowSMTPSender checks. """ special_rule_values = { '$mydomains': expand_mydomains } rules = {'allow': [], 'deny': []} if isinstance(policy, basestring): policy = [policy] for rule in policy: # Find rules that are actually special values, simply by # mapping the rule onto a key in "special_rule_values", a # dictionary with the corresponding value set to a function to # execute. if rule in special_rule_values: special_rules = special_rule_values[rule]() if rule.startswith("-"): rules['deny'].extend(special_rules) else: rules['allow'].extend(special_rules) continue # Lower-case the rule rule = rule.lower() # Also note the '-' cannot be passed on to the functions that # follow, so store the rule separately from the prefix that is # prepended to deny rules. if rule.startswith("-"): _prefix = '-' _rule = rule[1:] else: _prefix = '' _rule = rule # See if the value is an LDAP DN ldap_dn = self.parse_ldap_dn(_rule) if ldap_dn is not None and len(ldap_dn) > 0: if _prefix == '-': rules['deny'].extend(ldap_dn) else: rules['allow'].extend(ldap_dn) else: ldap_uri = self.parse_ldap_uri(_rule) if ldap_uri is not None and len(ldap_uri) > 0: if _prefix == '-': rules['deny'].extend(ldap_uri) else: rules['allow'].extend(ldap_uri) else: if rule.startswith("-"): rules['deny'].append(rule[1:]) else: rules['allow'].append(rule) allowed = False for rule in rules['allow']: deny_override = False if _object is not None and _object.endswith(rule): for deny_rule in rules['deny']: if deny_rule.endswith(rule): deny_override = True if not deny_override: allowed = True denied = False for rule in rules['deny']: allow_override = False if _object is not None and _object.endswith(rule): if not allowed: denied = True continue else: for allow_rule in rules['allow']: if allow_rule.endswith(rule): allow_override = True if not allow_override: denied = True if not denied: allowed = True return allowed def verify_alias(self): """ Verify whether the user authenticated for this policy request is using an alias of its primary authentication ID / attribute. John.Doe@example.org (mail) for example could be sending with envelope sender jdoe@example.org (mailAlternateAddress, alias). """ search_attrs = conf.get_list(self.sasl_domain, 'address_search_attrs') if search_attrs is None or \ (isinstance(search_attrs, list) and len(search_attrs) == 0): search_attrs = conf.get_list(self.sasl_domain, 'mail_attributes') if search_attrs is None or \ (isinstance(search_attrs, list) and len(search_attrs) == 0): search_attrs = conf.get_list('kolab_smtp_access_policy', 'address_search_attrs') if search_attrs is None or \ (isinstance(search_attrs, list) and len(search_attrs) == 0): search_attrs = conf.get_list( conf.get('kolab', 'auth_mechanism'), 'mail_attributes' ) want_attrs = [] for search_attr in search_attrs: if search_attr not in self.sasl_user: want_attrs.append(search_attr) if len(want_attrs) > 0: self.sasl_user.update( self.auth.get_user_attributes( self.sasl_domain, self.sasl_user, want_attrs ) ) # Catch a user using one of its own alias addresses. for search_attr in search_attrs: if search_attr in self.sasl_user: if isinstance(self.sasl_user[search_attr], list): if self.sender.lower() in [x.lower() for x in self.sasl_user[search_attr]]: return True elif self.sasl_user[search_attr].lower() == self.sender.lower(): return True return False def verify_authenticity(self): """ Verify that the SASL username or lack thereof corresponds with allowing or disallowing authenticated users. If an SASL username is supplied, use it to obtain the authentication database user object including all attributes we may find ourselves interested in. """ if self.sasl_username is None: if not conf.allow_unauthenticated: reject(_("Unauthorized access not allowed")) else: # If unauthenticated is allowed, I have nothing to do here. return True sasl_username = self.sasl_username # If we have an sasl_username, find the user object in the # authentication database, along with the attributes we are # interested in. if self.sasl_domain is None: if len(self.sasl_username.split('@')) > 1: self.sasl_domain = self.sasl_username.split('@')[1] else: self.sasl_domain = conf.get('kolab', 'primary_domain') sasl_username = "%s@%s" % ( self.sasl_username, self.sasl_domain ) if self.auth is None: self.auth = Auth(self.sasl_domain) elif not self.auth.domain == self.sasl_domain: self.auth = Auth(self.sasl_domain) sasl_users = self.auth.find_recipient(sasl_username, domain=self.sasl_domain) if isinstance(sasl_users, list): if len(sasl_users) == 0: log.error(_("Could not find recipient")) return False else: self.sasl_user = {'dn': sasl_users[0]} elif isinstance(sasl_users, basestring): self.sasl_user = {'dn': sasl_users} if not self.sasl_user['dn']: # Got a final answer here, do the caching thing. cache_update( function='verify_sender', sender=self.sender, recipients=self.recipients, result=(int)(False), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) reject( _("Could not find envelope sender user %s (511)") % ( self.sasl_username ) ) attrs = conf.get_list(self.sasl_domain, 'auth_attributes') if attrs is None or (isinstance(attrs, list) and len(attrs) == 0): attrs = conf.get_list( conf.get('kolab', 'auth_mechanism'), 'auth_attributes' ) mail_attrs = conf.get_list(self.sasl_domain, 'mail_attributes') if mail_attrs is None or \ (isinstance(mail_attrs, list) and len(mail_attrs) == 0): mail_attrs = conf.get_list( conf.get('kolab', 'auth_mechanism'), 'mail_attributes' ) if mail_attrs is not None: attrs.extend(mail_attrs) attrs.extend( [ 'kolabAllowSMTPRecipient', 'kolabAllowSMTPSender' ] ) attrs = list(set(attrs)) user_attrs = self.auth.get_user_attributes( self.sasl_domain, self.sasl_user, attrs ) user_attrs['dn'] = self.sasl_user['dn'] self.sasl_user = utils.normalize(user_attrs) log.debug( _("Obtained authenticated user details for %r: %r") % ( self.sasl_user['dn'], self.sasl_user.keys() ), level=8 ) def verify_delegate(self): """ Verify whether the authenticated user is a delegate of the envelope sender. """ sender_is_delegate = False if self.sender_domain is None: if len(self.sender.split('@')) > 1: self.sender_domain = self.sender.split('@')[1] else: self.sender_domain = conf.get('kolab', 'primary_domain') if self.sender == self.sasl_username: return True search_attrs = conf.get_list(self.sender_domain, 'mail_attributes') if search_attrs is None: search_attrs = conf.get_list( conf.get('kolab', 'auth_mechanism'), 'mail_attributes' ) sender_users = self.auth.find_recipient( self.sender, domain=self.sender_domain ) if isinstance(sender_users, list): if len(sender_users) > 1: # More then one sender user with this recipient address. # TODO: check each of the sender users found. self.sender_user = {'dn': sender_users[0]} elif len(sender_users) == 1: self.sender_user = {'dn': sender_users} else: self.sender_user = {'dn': False} elif isinstance(sender_users, basestring): self.sender_user = {'dn': sender_users} if not self.sender_user['dn']: cache_update( function='verify_sender', sender=self.sender, recipients=self.recipients, result=(int)(False), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) reject(_("Could not find envelope sender user %s") % (self.sender)) attrs = search_attrs attrs.extend( [ 'kolabAllowSMTPRecipient', 'kolabAllowSMTPSender', 'kolabDelegate' ] ) user_attrs = self.auth.get_user_attributes( self.sender_domain, self.sender_user, attrs ) user_attrs['dn'] = self.sender_user['dn'] self.sender_user = utils.normalize(user_attrs) if 'kolabdelegate' not in self.sender_user: # Got a final answer here, do the caching thing. if cache is not False: cache_update( function='verify_sender', sender=self.sender, recipients=self.recipients, result=(int)(False), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) reject( _("%s is unauthorized to send on behalf of %s") % ( self.sasl_user['dn'], self.sender_user['dn'] ) ) elif self.sender_user['kolabdelegate'] is None: # No delegates for this sender could be found. The user is # definitely NOT a delegate of the sender. log.warning( _("User %s attempted to use envelope sender address %s without authorization") % ( policy_request["sasl_username"], policy_request["sender"] ) ) # Got a final answer here, do the caching thing. if cache is not False: cache_update( function='verify_sender', sender=self.sender, recipients=self.recipients, result=(int)(False), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) reject( _("%s is unauthorized to send on behalf of %s") % ( self.sasl_user['dn'], self.sender_user['dn'] ) ) else: # See if we can match the value of the envelope sender delegates to # the actual sender sasl_username if self.sasl_user is None: sasl_users = self.auth.find_recipient( self.sasl_username, domain=self.sasl_domain ) if isinstance(sasl_users, list): if len(sasl_users) == 0: log.error(_("Could not find recipient")) return False else: self.sasl_user = {'dn': sasl_users[0]} elif isinstance(sasl_users, basestring): self.sasl_user = {'dn': sasl_users} # Possible values for the kolabDelegate attribute are: # a 'uid', a 'dn'. if 'uid' not in self.sasl_user: self.sasl_user['uid'] = self.auth.get_user_attribute( self.sasl_domain, self.sasl_user, 'uid' ) sender_delegates = self.sender_user['kolabdelegate'] if not type(sender_delegates) == list: sender_delegates = [sender_delegates] for sender_delegate in sender_delegates: if self.sasl_user['dn'] == sender_delegate: log.debug( _("Found user %s to be a delegate user of %s") % ( policy_request["sasl_username"], policy_request["sender"] ), level=8 ) sender_is_delegate = True elif self.sasl_user['uid'] == sender_delegate: log.debug( _("Found user %s to be a delegate user of %s") % ( policy_request["sasl_username"], policy_request["sender"] ), level=8 ) sender_is_delegate = True return sender_is_delegate def verify_recipient(self, recipient): """ Verify whether the sender is allowed send to this recipient, using the recipient's kolabAllowSMTPSender. """ self.recipient = recipient if not self.sasl_username == '' and self.sasl_username is not None: log.debug( _("Verifying authenticated sender '%(sender)s' with sasl_username '%(sasl_username)s' for recipient '%(recipient)s'") % ( self.__dict__ ), level=8 ) else: log.debug( _( "Verifying unauthenticated sender '%(sender)s' for recipient '%(recipient)s'" ) % ( self.__dict__ ), level=8 ) recipient_verified = False if cache is not False: records = cache_select( function='verify_recipient', sender=self.sender, recipient=recipient, sasl_username=self.sasl_username, sasl_sender=self.sasl_sender, ) if records is not None and len(records) == 1: log.info( _("Reproducing verify_recipient(%s, %s) from cache") % ( self.sender, recipient ) ) return records[0].value # TODO: Under some conditions, the recipient may not be fully qualified. # We'll cross that bridge when we get there, though. if len(recipient.split('@')) > 1: sasl_domain = recipient.split('@')[1] else: sasl_domain = conf.get('kolab', 'primary_domain') recipient = "%s@%s" % (recipient, sasl_domain) if not verify_domain(sasl_domain): if cache is not False: cache_update( function='verify_recipient', sender=self.sender, recipient=recipient, result=(int)(True), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) return True if self.auth is None: self.auth = Auth(sasl_domain) elif not self.auth.domain == sasl_domain: self.auth = Auth(sasl_domain) if verify_domain(sasl_domain): if self.auth.domains is not None and sasl_domain in self.auth.domains: log.debug( _("Using authentication domain %s instead of %s") % ( self.auth.domains[sasl_domain], sasl_domain ), level=8 ) sasl_domain = self.auth.domains[sasl_domain] else: log.debug( _("Domain %s is a primary domain") % (sasl_domain), level=8 ) else: log.warning( _("Checking the recipient for domain %s that is not ours.") % (sasl_domain) ) return True recipients = self.auth.find_recipient( normalize_address(recipient), domain=sasl_domain, ) if isinstance(recipients, list): if len(recipients) > 1: log.info( _("This recipient address is related to multiple object entries and the SMTP Access Policy can therefore not restrict message flow") ) cache_update( function='verify_recipient', sender=self.sender, recipient=normalize_address(recipient), result=(int)(True), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) return True elif len(recipients) == 1: _recipient = {'dn': recipients[0]} else: log.debug( _("Recipient address %r not found. Allowing since the MTA was configured to accept the recipient.") % ( normalize_address(recipient) ), level=3 ) cache_update( function='verify_recipient', sender=self.sender, recipient=normalize_address(recipient), result=(int)(True), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) return True elif isinstance(recipients, basestring): _recipient = {'dn': recipients} # We have gotten an invalid recipient. We need to catch this case, # because testing can input invalid recipients, and so can faulty # applications, or misconfigured servers. if not _recipient['dn']: if not conf.allow_unauthenticated: cache_update( function='verify_recipient', sender=self.sender, recipient=normalize_address(recipient), result=(int)(False), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) reject(_("Invalid recipient")) else: cache_update( function='verify_recipient', sender=self.sender, recipient=normalize_address(recipient), result=(int)(True), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) log.debug(_("Could not find this user, accepting"), level=8) return True if _recipient['dn'] is not False: recipient_policy = self.auth.get_entry_attribute( sasl_domain, _recipient['dn'], 'kolabAllowSMTPSender' ) # If no such attribute has been specified, allow if recipient_policy is None: cache_update( function='verify_recipient', sender=self.sender, recipient=normalize_address(recipient), result=(int)(True), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) recipient_verified = True # Otherwise, parse the policy obtained with the subject of the policy # being the recipient, and the object to apply the policy to being the # sender. else: recipient_verified = self.parse_policy( recipient, self.sender, recipient_policy ) cache_update( function='verify_recipient', sender=self.sender, recipient=normalize_address(recipient), result=(int)(recipient_verified), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender ) return recipient_verified def verify_recipients(self): """ Verify whether the sender is allowed send to the recipients in this policy request, using each recipient's kolabAllowSMTPSender. Note there may be multiple recipients in this policy request, and therefor self.recipients is a list - walk through that list. """ recipients_verified = True if cache is not False: records = cache_select( function='verify_recipient', sender=self.sender, recipients=self.recipients, sasl_username=self.sasl_username, sasl_sender=self.sasl_sender, ) if records is not None and len(records) == len(self.recipients): log.debug("Euh, what am I doing here?") for record in records: recipient_found = False for recipient in self.recipients: if recipient == record.recipient: recipient_found = True if not recipient_found: reject( _("Sender %s is not allowed to send to recipient %s") % ( self.sender, recipient ) ) for recipient in self.recipients: recipient_verified = self.verify_recipient(recipient) if not recipient_verified: recipients_verified = False return recipients_verified def verify_sender(self): """ Verify the sender's access policy. 1) Verify whether the sasl_username is allowed to send using the envelope sender address, with the kolabDelegate attribute associated with the LDAP object that has the envelope sender address. 2) Verify whether the sender is allowed to send to recipient(s) listed on the sender's object. A third potential action could be to check the recipient object to see if the sender is allowed to send to the recipient by the recipient's kolabAllowSMTPSender, but this is done in verify_recipients(). """ sender_verified = False if self.sender is None: # Trusted host? if not hasattr(self, 'client_address') or \ self.client_address == "" or \ self.client_address is None: # Nothing to compare to. return False try: import netaddr networks = conf.get_list( 'kolab_smtp_access_policy', 'empty_sender_hosts' ) for network in networks: if netaddr.IPNetwork(self.client_address) in netaddr.IPNetwork(network): return True except ImportError as errmsg: return False if cache is not False: records = cache_select( sender=self.sender, recipients=self.recipients, sasl_username=self.sasl_username, sasl_sender=self.sasl_sender, function='verify_sender' ) if records is not None and len(records) == len(self.recipients): log.info( _("Reproducing verify_sender(%r) from cache") % (self.__dict__) ) for record in records: recipient_found = False for recipient in self.recipients: if recipient == record.recipient: recipient_found = True if recipient_found: if not record.value: reject( _("Sender %s is not allowed to send to recipient %s") % ( self.sender, recipient ) ) if record.data is not None: self.__dict__.update(record.data) return True if self.verify_authenticity() is False: reject(_("Unverifiable sender.")) if self.sasl_user is not None: self.sasl_user_uses_alias = self.verify_alias() if not self.sasl_user_uses_alias: log.debug(_("Sender is not using an alias"), level=8) self.sasl_user_is_delegate = self.verify_delegate() # If the authenticated user is using delegate functionality, apply the # recipient policy attribute for the envelope sender. if self.sasl_user_is_delegate is False: if self.sasl_user_uses_alias is False: if not conf.allow_unauthenticated: reject(_("Sender uses unauthorized envelope sender address")) if self.sasl_user_is_delegate: # Apply the recipient policy for the sender using the envelope # sender user object. recipient_policy_domain = self.sender_domain recipient_policy_sender = self.sender recipient_policy_user = self.sender_user elif self.sasl_user is not None: # Apply the recipient policy from the authenticated user. recipient_policy_domain = self.sasl_domain recipient_policy_sender = self.sasl_username recipient_policy_user = self.sasl_user else: if not conf.allow_unauthenticated: reject(_("Could not verify sender")) else: recipient_policy_domain = self.sender_domain recipient_policy_sender = self.sender recipient_policy_user = self.sender_user log.debug( _("Verifying whether sender is allowed to send to recipient using sender policy"), level=8 ) if 'kolaballowsmtprecipient' in recipient_policy_user: recipient_policy = recipient_policy_user['kolaballowsmtprecipient'] else: recipient_policy = self.auth.get_user_attribute( recipient_policy_domain, recipient_policy_user, 'kolabAllowSMTPRecipient' ) log.debug(_("Result is %r") % (recipient_policy), level=8) # If no such attribute has been specified, allow if recipient_policy is None: log.debug( _("No recipient policy restrictions exist for this sender"), level=8 ) sender_verified = True # Otherwise,parse the policy obtained. else: log.debug( _("Found a recipient policy to apply for this sender."), level=8 ) recipient_allowed = None for recipient in self.recipients: recipient_allowed = self.parse_policy( recipient_policy_sender, recipient, recipient_policy ) if not recipient_allowed: reject( _("Sender %s not allowed to send to recipient %s") % ( recipient_policy_user['dn'], recipient ) ) sender_verified = True if cache is not False: data = { 'sasl_user_uses_alias': self.sasl_user_uses_alias, 'sasl_user_is_delegate': self.sasl_user_is_delegate, 'sender_domain': self.sender_domain, } cache_update( function='verify_sender', sender=self.sender, recipients=self.recipients, result=(int)(sender_verified), sasl_username=self.sasl_username, sasl_sender=self.sasl_sender, data=data ) return sender_verified def cache_cleanup(): if cache is not True: return log.debug(_("Cleaning up the cache"), level=8) session.query(PolicyResult).filter( PolicyResult.created < ((int)(time.time()) - cache_expire) ).delete() session.commit() def cache_init(): global cache, cache_expire, session if conf.has_section('kolab_smtp_access_policy'): if conf.has_option('kolab_smtp_access_policy', 'cache_uri'): cache_uri = conf.get('kolab_smtp_access_policy', 'cache_uri') cache = True if conf.has_option('kolab_smtp_access_policy', 'retention'): cache_expire = (int)( conf.get( 'kolab_smtp_access_policy', 'retention' ) ) else: return False else: return False if conf.debuglevel > 8: engine = create_engine(cache_uri, echo=True) else: engine = create_engine(cache_uri, echo=False) if conf.drop_tables: log.info(_("Dropping caching tables from database")) policy_result_table.drop(engine, checkfirst=True) statistic_table.drop(engine, checkfirst=True) return False try: metadata.create_all(engine) except sqlalchemy.exc.OperationalError as e: log.error(_("Operational Error in caching: %s" % (e))) return False Session = sessionmaker(bind=engine) session = Session() cache_cleanup() return cache def cache_select( function, sender, recipient='', recipients=[], sasl_username='', sasl_sender=''): if cache is not True: return None if not recipient == '' and recipients == []: recipients = [recipient] return session.query( PolicyResult ).filter_by( key=function, sender=sender, sasl_username=sasl_username, sasl_sender=sasl_sender ).filter( PolicyResult.recipient.in_(recipients) ).filter( PolicyResult.created >= ((int)(time.time()) - cache_expire) ).all() def cache_insert( function, sender, recipient='', recipients=[], result=None, sasl_username='', sasl_sender='', data=None): if cache is not True: return [] log.debug( _("Caching the policy result with timestamp %d") % ( (int)(time.time()) ), level=8 ) if not recipient == '' and recipients == []: recipients = [recipient] for recipient in recipients: session.add( PolicyResult( key=function, value=result, sender=sender, recipient=recipient, sasl_username=sasl_username, sasl_sender=sasl_sender, data=data ) ) session.commit() def cache_update( function, sender, recipient='', recipients=[], result=None, sasl_username='', sasl_sender='', data=None): """ Insert an updated set of rows into the cache depending on the necessity """ if cache is not True: return records = [] _records = cache_select( function, sender, recipient, recipients, sasl_username, sasl_sender ) for record in _records: if record.value == (int)(result): records.append(record) if not recipient == '' and recipients == []: recipients = [recipient] for recipient in recipients: recipient_found = False for record in records: if record.recipient == recipient: recipient_found = True if not recipient_found: cache_insert( function=function, sender=sender, recipient=recipient, result=result, sasl_username=sasl_username, sasl_sender=sasl_sender, data=data ) def defer_if_permit(message, policy_request=None): log.info(_("Returning action DEFER_IF_PERMIT: %s") % (message)) print("action=DEFER_IF_PERMIT %s\n\n" % (message)) sys.exit(0) def dunno(message, policy_request=None): log.info(_("Returning action DUNNO: %s") % (message)) print("action=DUNNO %s\n\n" % (message)) sys.exit(0) def hold(message, policy_request=None): log.info(_("Returning action HOLD: %s") % (message)) print("action=HOLD %s\n\n" % (message)) sys.exit(0) def permit(message, policy_request=None): log.info(_("Returning action PERMIT: %s") % (message)) # If we have no policy request, we have been called for a reason, # and everything relevant has been figured out already. if policy_request is None: print("action=PERMIT\n\n") sys.exit(0) # If the user is not authenticated, there's no reason to do # extra checks here -- to have been performed already. if not hasattr(policy_request, 'sasl_username'): print("action=PERMIT\n\n") sys.exit(0) # Same here. if policy_request.sasl_username is None: print("action=PERMIT\n\n") sys.exit(0) delegate_sender_header = None alias_sender_header = None # If the sender is a delegate of the envelope sender address, take into # account the preferred domain policy for appending the Sender and/or # X-Sender headers. # # Note that a delegatee by very definition is not using an alias. # if policy_request.sasl_user_is_delegate: # Domain-specific setting? if policy_request.sender_domain is not None: delegate_sender_header = conf.get(policy_request.sender_domain, 'delegate_sender_header') # Global setting? if delegate_sender_header is None: delegate_sender_header = conf.get('kolab_smtp_access_policy', 'delegate_sender_header') # Default if delegate_sender_header is None: delegate_sender_header = True # If the sender is using an alias as the envelope sender address, take # into account the preferred domain policy for appending the Sender # and/or X-Sender headers. elif policy_request.sasl_user_uses_alias: # Domain-specific setting? if policy_request.sender_domain is not None: alias_sender_header = conf.get(policy_request.sender_domain, 'alias_sender_header') # Global setting? if alias_sender_header is None: alias_sender_header = conf.get('kolab_smtp_access_policy', 'alias_sender_header') # Default if alias_sender_header is None: alias_sender_header = True # Make the values booleans delegate_sender_header = utils.true_or_false(delegate_sender_header) alias_sender_header = utils.true_or_false(alias_sender_header) # Do we use a (simple) encryption key to obscure the headers' contents? # Note that using an encryption key voids the actual use of proper Sender # and X-Sender headers such as they could be interpreted by a client # application. enc_key = None if policy_request.sender_domain is not None: enc_key = conf.get(policy_request.sender_domain, 'sender_header_enc_key') if enc_key is None: enc_key = conf.get('kolab_smtp_access_policy', 'sender_header_enc_key') sender_header = None xsender_header = None if delegate_sender_header or alias_sender_header: # Domain specific? sender_header = conf.get(policy_request.sender_domain, 'sender_header') # Global setting? if sender_header is None: sender_header = conf.get('kolab_smtp_access_policy', 'sender_header') # Default if sender_header is None: sender_header = True # Domain specific? xsender_header = conf.get(policy_request.sender_domain, 'xsender_header') # Global setting? if xsender_header is None: xsender_header = conf.get('kolab_smtp_access_policy', 'xsender_header') # Default if xsender_header is None: xsender_header = True # Note that if the user is not a delegatee, and not using an alias, the sender # address is the envelope sender address, and the defaults for sender_header # and xsender_header being None, ultimately evaluating to False seems # appropriate. # Make the values booleans sender_header = utils.true_or_false(sender_header) xsender_header = utils.true_or_false(xsender_header) if sender_header or xsender_header: # Do the encoding, if any if enc_key is not None: header = 'X-Authenticated-As' xheader = None sender = utils.encode(enc_key, policy_request.sasl_username) else: header = 'Sender' xheader = 'X-Sender' sender = policy_request.sasl_username if sender_header: print("action=PREPEND %s: %s" % (header, sender)) if xsender_header and xheader is not None: print("action=PREPEND %s: %s" % (xheader, sender)) print("action=PERMIT\n\n") sys.exit(0) def reject(message, policy_request=None): log.info(_("Returning action REJECT: %s") % (message)) print("action=REJECT %s\n\n" % (message)) sys.exit(0) def expand_mydomains(): """ Return a list of my domains. """ global auth, mydomains if mydomains is not None: return mydomains auth.connect() mydomains = auth.list_domains() return mydomains.keys() def normalize_address(email_address): """ Parse an address; Strip off anything after a recipient delimiter. """ # TODO: Recipient delimiter is configurable. if len(email_address.split("+")) > 1: # Take the first part split by recipient delimiter and the last part # split by '@'. return "%s@%s" % ( email_address.split("+")[0].lower(), # TODO: Under some conditions, the recipient may not be fully # qualified. We'll cross that bridge when we get there, though. email_address.split('@')[1].lower() ) else: return email_address.lower() def read_request_input(): """ Read a single policy request from sys.stdin, and return a dictionary containing the request. """ start_time = time.time() log.debug(_("Starting to loop for new request")) policy_request = {} end_of_request = False while not end_of_request: if (time.time() - start_time) >= conf.timeout: log.warning(_("Timeout for policy request reading exceeded")) sys.exit(0) request_line = sys.stdin.readline() if request_line.strip() == '': if 'request' in policy_request: log.debug(_("End of current request"), level=8) end_of_request = True else: request_line = request_line.strip() log.debug(_("Getting line: %s") % (request_line), level=8) policy_request[request_line.split('=')[0]] = \ '='.join(request_line.split('=')[1:]).lower() log.debug(_("Returning request")) return policy_request def verify_domain(domain): """ Verify whether the domain is internal (mine) or external. """ global auth, mydomains if mydomains is not None: return domain in mydomains auth.connect() domain_verified = False mydomains = auth.list_domains() if mydomains is not None and domain in mydomains: domain_verified = True else: domain_verified = False return domain_verified if __name__ == "__main__": access_policy_group = conf.add_cli_parser_option_group( _("Access Policy Options") ) access_policy_group.add_option( "--drop-tables", dest="drop_tables", action="store_true", default=False, help=_("Drop the caching tables from the database and exit.") ) access_policy_group.add_option( "--timeout", dest="timeout", action="store", default=10, help=_("SMTP Policy request timeout.") ) access_policy_group.add_option( "--verify-recipient", dest="verify_recipient", action="store_true", default=False, help=_("Verify the recipient access policy.") ) access_policy_group.add_option( "--verify-sender", dest="verify_sender", action="store_true", default=False, help=_("Verify the sender access policy.") ) access_policy_group.add_option( "--allow-unauthenticated", dest="allow_unauthenticated", action="store_true", default=False, help=_("Allow unauthenticated senders.") ) conf.finalize_conf() auth = Auth() cache = cache_init() policy_requests = {} if conf.drop_tables: sys.exit(0) # Start the work while True: policy_request = read_request_input() instance = policy_request['instance'] log.debug(_("Got request instance %s") % (instance)) if instance in policy_requests: policy_requests[instance].add_request(policy_request) else: policy_requests[instance] = PolicyRequest(policy_request) protocol_state = policy_request['protocol_state'].strip().lower() log.debug( _("Request instance %s is in state %s") % ( instance, protocol_state ) ) if not protocol_state == 'data': print("action=DUNNO\n\n") sys.stdout.flush() # We can recognize being in the DATA part by the recipient_count being # set to a non-zero value and the protocol_state being set to 'data'. # Note that the input we're getting is a string, not an integer. else: sender_allowed = False recipient_allowed = False try: if conf.verify_sender: sender_allowed = policy_requests[instance].verify_sender() else: sender_allowed = True if conf.verify_recipient: recipient_allowed = policy_requests[instance].verify_recipients() else: recipient_allowed = True except Exception as errmsg: import traceback log.error(_("Unhandled exception caught: %r") % (errmsg)) log.error(traceback.format_exc()) if not sender_allowed: reject(_("Sender access denied")) elif not recipient_allowed: reject(_("Recipient access denied")) else: permit(_("No objections"), policy_requests[instance]) diff --git a/pykolab/cli/sieve/cmd_list.py b/pykolab/cli/sieve/cmd_list.py index 20c967a..0ed7434 100644 --- a/pykolab/cli/sieve/cmd_list.py +++ b/pykolab/cli/sieve/cmd_list.py @@ -1,121 +1,124 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import sys import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import pykolab from pykolab import utils from pykolab.auth import Auth from pykolab.cli import commands from pykolab.translate import _ log = pykolab.getLogger('pykolab.cli') conf = pykolab.getConf() def __init__(): commands.register( 'list', execute, group='sieve', description=description() ) def description(): return """List a user's sieve scripts.""" def execute(*args, **kw): try: address = conf.cli_args.pop(0) except: address = utils.ask_question(_("Email Address")) auth = Auth() auth.connect() user = auth.find_recipient(address) # Get the main, default backend backend = conf.get('kolab', 'imap_backend') if len(address.split('@')) > 1: domain = address.split('@')[1] else: domain = conf.get('kolab', 'primary_domain') if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: uri = conf.get(backend, 'uri') hostname = None port = None result = urlparse(uri) if hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') port = 4190 # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') import sievelib.managesieve sieveclient = sievelib.managesieve.Client( hostname, port, conf.debuglevel > 8 ) sieveclient.connect(None, None, True) result = sieveclient._plain_authentication( admin_login, admin_password, address ) if not result: print("LOGIN FAILED??") sieveclient.authenticated = True result = sieveclient.listscripts() if result == None: print("No scripts") sys.exit(0) (active, scripts) = result print("%s (active)" % (active)) for script in scripts: print(script) diff --git a/pykolab/cli/sieve/cmd_put.py b/pykolab/cli/sieve/cmd_put.py index 7286370..5ab937f 100644 --- a/pykolab/cli/sieve/cmd_put.py +++ b/pykolab/cli/sieve/cmd_put.py @@ -1,93 +1,96 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import pykolab from pykolab.auth import Auth from pykolab.cli import commands from pykolab.translate import _ log = pykolab.getLogger('pykolab.cli') conf = pykolab.getConf() import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse def __init__(): commands.register('put', execute, group='sieve', description=description()) def description(): return """Put a script to a user's list of sieve scripts.""" def execute(*args, **kw): try: address = conf.cli_args.pop(0) except: address = utils.ask_question(_("Email Address")) script_to_put = conf.cli_args.pop(0) script_put_name = conf.cli_args.pop(0) auth = Auth() auth.connect() user = auth.find_recipient(address) # Get the main, default backend backend = conf.get('kolab', 'imap_backend') if len(address.split('@')) > 1: domain = address.split('@')[1] else: domain = conf.get('kolab', 'primary_domain') if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: uri = conf.get(backend, 'uri') hostname = None port = None result = urlparse(uri) if hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') port = 4190 # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') import sievelib.managesieve sieveclient = sievelib.managesieve.Client(hostname, port, False) sieveclient.connect(None, None, True) sieveclient._plain_authentication(admin_login, admin_password, address) sieveclient.authenticated = True sieveclient.putscript(script_put_name, open(script_to_put, "r").read()) diff --git a/pykolab/cli/sieve/cmd_refresh.py b/pykolab/cli/sieve/cmd_refresh.py index 0f93208..ec0ac65 100644 --- a/pykolab/cli/sieve/cmd_refresh.py +++ b/pykolab/cli/sieve/cmd_refresh.py @@ -1,420 +1,423 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import pykolab from pykolab import utils from pykolab.auth import Auth from pykolab.cli import commands from pykolab.translate import _ log = pykolab.getLogger('pykolab.cli') conf = pykolab.getConf() import sys import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse def __init__(): commands.register('refresh', execute, group='sieve', description=description()) def description(): return """Refresh a user's managed and contributed sieve scripts.""" def execute(*args, **kw): try: address = conf.cli_args.pop(0) except: address = utils.ask_question(_("Email Address")) auth = Auth() auth.connect() user = auth.find_recipient(address) # Get the main, default backend backend = conf.get('kolab', 'imap_backend') if len(address.split('@')) > 1: domain = address.split('@')[1] else: domain = conf.get('kolab', 'primary_domain') if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: uri = conf.get(backend, 'uri') hostname = None port = None result = urlparse(uri) if hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') port = 4190 # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') import sievelib.managesieve sieveclient = sievelib.managesieve.Client(hostname, port, conf.debuglevel > 8) sieveclient.connect(None, None, True) sieveclient._plain_authentication(admin_login, admin_password, address) sieveclient.authenticated = True result = sieveclient.listscripts() if result == None: active = None scripts = [] else: active, scripts = result log.debug(_("Found the following scripts for user %s: %s") % (address, ','.join(scripts)), level=8) log.debug(_("And the following script is active for user %s: %s") % (address, active), level=8) mgmt_required_extensions = [] mgmt_script = """# # MANAGEMENT # """ user = auth.get_entry_attributes(domain, user, ['*']) # # Vacation settings (a.k.a. Out of Office) # vacation_active = None vacation_text = None vacation_uce = None vacation_noreact_domains = None vacation_react_domains = None vacation_active_attr = conf.get('sieve', 'vacation_active_attr') vacation_text_attr = conf.get('sieve', 'vacation_text_attr') vacation_uce_attr = conf.get('sieve', 'vacation_uce_attr') vacation_noreact_domains_attr = conf.get('sieve', 'vacation_noreact_domains_attr') vacation_react_domains_attr = conf.get('sieve', 'vacation_react_domains_attr') if not vacation_text_attr == None: if vacation_active_attr in user: vacation_active = utils.true_or_false(user[vacation_active_attr]) else: vacation_active = False if vacation_text_attr in user: vacation_text = user[vacation_text_attr] else: vacation_active = False if vacation_uce_attr in user: vacation_uce = utils.true_or_false(user[vacation_uce_attr]) else: vacation_uce = False if vacation_react_domains_attr in user: if isinstance(user[vacation_react_domains_attr], list): vacation_react_domains = user[vacation_react_domains_attr] else: vacation_react_domains = [ user[vacation_react_domains_attr] ] else: if vacation_noreact_domains_attr in user: if isinstance(user[vacation_noreact_domains_attr], list): vacation_noreact_domains = user[vacation_noreact_domains_attr] else: vacation_noreact_domains = [ user[vacation_noreact_domains_attr] ] else: vacation_noreact_domains = [] # # Delivery to Folder # dtf_active_attr = conf.get('sieve', 'deliver_to_folder_active') if not dtf_active_attr == None: if dtf_active_attr in user: dtf_active = utils.true_or_false(user[dtf_active_attr]) else: dtf_active = False else: # TODO: Not necessarily de-activated, the *Active attributes are # not supposed to charge this - check the deliver_to_folder_attr # attribute value for a value. dtf_active = False if dtf_active: dtf_folder_name_attr = conf.get('sieve', 'deliver_to_folder_attr') if not dtf_folder_name_attr == None: if dtf_folder_name_attr in user: dtf_folder = user[dtf_folder_name_attr] else: log.warning(_("Delivery to folder active, but no folder name attribute available for user %r") % (user)) dtf_active = False else: log.error(_("Delivery to folder active, but no folder name attribute configured")) dtf_active = False # # Folder name to delivery spam to. # # Global or local. # sdf_filter = True sdf = conf.get('sieve', 'spam_global_folder') if sdf == None: sdf = conf.get('sieve', 'spam_personal_folder') if sdf == None: sdf_filter = False # # Mail forwarding # forward_active = None forward_addresses = [] forward_keepcopy = None forward_uce = None forward_active_attr = conf.get('sieve', 'forward_address_active') if not forward_active_attr == None: if forward_active_attr in user: forward_active = utils.true_or_false(user[forward_active_attr]) else: forward_active = False if not forward_active == False: forward_address_attr = conf.get('sieve', 'forward_address_attr') if forward_address_attr in user: if isinstance(user[forward_address_attr], basestring): forward_addresses = [ user[forward_address_attr] ] elif isinstance(user[forward_address_attr], str): forward_addresses = [ user[forward_address_attr] ] else: forward_addresses = user[forward_address_attr] if len(forward_addresses) == 0: forward_active = False forward_keepcopy_attr = conf.get('sieve', 'forward_keepcopy_active') if not forward_keepcopy_attr == None: if forward_keepcopy_attr in user: forward_keepcopy = utils.true_or_false(user[forward_keepcopy_attr]) else: forward_keepcopy = False forward_uce_attr = conf.get('sieve', 'forward_uce_active') if not forward_uce_attr == None: if forward_uce_attr in user: forward_uce = utils.true_or_false(user[forward_uce_attr]) else: forward_uce = False if vacation_active: mgmt_required_extensions.append('vacation') mgmt_required_extensions.append('envelope') if dtf_active: mgmt_required_extensions.append('fileinto') if forward_active and (len(forward_addresses) > 1 or forward_keepcopy): mgmt_required_extensions.append('copy') if sdf_filter: mgmt_required_extensions.append('fileinto') import sievelib.factory mgmt_script = sievelib.factory.FiltersSet("MANAGEMENT") for required_extension in mgmt_required_extensions: mgmt_script.require(required_extension) mgmt_script.require('fileinto') if vacation_active: if not vacation_react_domains == None and len(vacation_react_domains) > 0: mgmt_script.addfilter( 'vacation', [('envelope', ':domain', ":is", "from", vacation_react_domains)], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) elif not vacation_noreact_domains == None and len(vacation_noreact_domains) > 0: mgmt_script.addfilter( 'vacation', [('not', ('envelope', ':domain', ":is", "from", vacation_noreact_domains))], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) else: mgmt_script.addfilter( 'vacation', [('true',)], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) if forward_active: forward_rules = [] # Principle can be demonstrated by: # # python -c "print ','.join(['a','b','c'][:-1])" # for forward_copy in forward_addresses[:-1]: forward_rules.append(("redirect", ":copy", forward_copy)) if forward_keepcopy: # Principle can be demonstrated by: # # python -c "print ','.join(['a','b','c'][-1])" # if forward_uce: rule_name = 'forward-uce-keepcopy' else: rule_name = 'forward-keepcopy' forward_rules.append(("redirect", ":copy", forward_addresses[-1])) else: if forward_uce: rule_name = 'forward-uce' else: rule_name = 'forward' forward_rules.append(("redirect", forward_addresses[-1])) forward_rules.append(("stop")) if forward_uce: mgmt_script.addfilter(rule_name, ['true'], forward_rules) else: # NOTE: Messages with no X-Spam-Status header need to be matched # too, and this does exactly that. mgmt_script.addfilter(rule_name, [("not", ("X-Spam-Status", ":matches", "Yes,*"))], forward_rules) if sdf_filter: mgmt_script.addfilter('spam_delivery_folder', [("X-Spam-Status", ":matches", "Yes,*")], [("fileinto", "INBOX/Spam"), ("stop")]) if dtf_active: mgmt_script.addfilter('delivery_to_folder', ['true'], [("fileinto", dtf_folder)]) mgmt_script = mgmt_script.__str__() log.debug(_("MANAGEMENT script for user %s contents: %r") % (address,mgmt_script), level=8) result = sieveclient.putscript("MANAGEMENT", mgmt_script) if not result: log.error(_("Uploading script MANAGEMENT failed for user %s") % (address)) else: log.debug(_("Uploading script MANAGEMENT for user %s succeeded") % (address), level=8) user_script = """# # User # require ["include"]; """ for script in scripts: if not script in [ "MASTER", "MANAGEMENT", "USER" ]: log.debug(_("Including script %s in USER (for user %s)") % (script,address) ,level=8) user_script = """%s include :personal "%s"; """ % (user_script, script) result = sieveclient.putscript("USER", user_script) if not result: log.error(_("Uploading script USER failed for user %s") % (address)) else: log.debug(_("Uploading script USER for user %s succeeded") % (address), level=8) result = sieveclient.putscript("MASTER", """# # MASTER # # This file is authoritative for your system and MUST BE KEPT ACTIVE. # # Altering it is likely to render your account dysfunctional and may # be violating your organizational or corporate policies. # # For more information on the mechanism and the conventions behind # this script, see http://wiki.kolab.org/KEP:14 # require ["include"]; # OPTIONAL: Includes for all or a group of users # include :global "all-users"; # include :global "this-group-of-users"; # The script maintained by the general management system include :personal "MANAGEMENT"; # The script(s) maintained by one or more editors available to the user include :personal "USER"; """) if not result: log.error(_("Uploading script MASTER failed for user %s") % (address)) else: log.debug(_("Uploading script MASTER for user %s succeeded") % (address), level=8) sieveclient.setactive("MASTER") diff --git a/pykolab/cli/sieve/cmd_test.py b/pykolab/cli/sieve/cmd_test.py index 7906570..0b7bdeb 100644 --- a/pykolab/cli/sieve/cmd_test.py +++ b/pykolab/cli/sieve/cmd_test.py @@ -1,129 +1,132 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import pykolab from pykolab.auth import Auth from pykolab.cli import commands from pykolab.translate import _ log = pykolab.getLogger('pykolab.cli') conf = pykolab.getConf() import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse def __init__(): commands.register('test', execute, group='sieve', description=description()) def description(): return """Syntactically check a user's sieve scripts.""" def execute(*args, **kw): try: address = conf.cli_args.pop(0) except: address = utils.ask_question(_("Email Address")) auth = Auth() auth.connect() user = auth.find_recipient(address) # Get the main, default backend backend = conf.get('kolab', 'imap_backend') if len(address.split('@')) > 1: domain = address.split('@')[1] else: domain = conf.get('kolab', 'primary_domain') if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: uri = conf.get(backend, 'uri') hostname = None port = None result = urlparse(uri) if hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') port = 4190 # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') import sievelib.managesieve sieveclient = sievelib.managesieve.Client(hostname, port, True) sieveclient.connect(None, None, True) sieveclient._plain_authentication(admin_login, admin_password, address) sieveclient.authenticated = True active, scripts = sieveclient.listscripts() print("%s (active)" % (active)) _all_scripts = [ active ] + scripts _used_scripts = [ active ] _included_scripts = [] _a_script = sieveclient.getscript(active) print(_a_script) import sievelib.parser _a_parser = sievelib.parser.Parser(debug=True) _a_parsed = _a_parser.parse(_a_script) #print "%r" % (_a_parsed) if not _a_parsed: print(_a_parser.error) print("%r" % (_a_parser.result)) for _a_command in _a_parser.result: print(_a_command.name, _a_command.arguments) if len(_a_command.children) > 0: for _a_child in _a_command.children: print(" ", _a_child.name, _a_child.arguments) if _a_command.name == "include": if _a_command.arguments["script"].strip('"') in scripts: print("OK") _used_scripts.append(_a_command.arguments["script"].strip('"')) else: print("Not OK") for script in scripts: print(script) diff --git a/pykolab/conf/__init__.py b/pykolab/conf/__init__.py index 6de3c8e..ceb77a7 100644 --- a/pykolab/conf/__init__.py +++ b/pykolab/conf/__init__.py @@ -1,796 +1,799 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from __future__ import print_function import logging import os import sys from optparse import OptionParser -from ConfigParser import SafeConfigParser +try: + from ConfigParser import SafeConfigParser +except ImportError: + from configparser import SafeConfigParser import pykolab from pykolab.conf.defaults import Defaults from pykolab.constants import * from pykolab.translate import _ log = pykolab.getLogger('pykolab.conf') class Conf(object): def __init__(self): """ self.cli_args == Arguments passed on the CLI self.cli_keywords == Parser results (again, CLI) self.cli_parser == The actual Parser (from OptionParser) self.plugins == Our Kolab Plugins """ self.cli_parser = None self.cli_args = None self.cli_keywords = None self.entitlement = None self.changelog = {} try: from pykolab.conf.entitlement import Entitlement entitlements = True except Exception: entitlements = False pass if entitlements: self.entitlement = Entitlement().get() self.plugins = None # The location where our configuration parser is going to end up self.cfg_parser = None # Create the options self.create_options() def finalize_conf(self, fatal=True): self.create_options_from_plugins() self.parse_options(fatal=fatal) # The defaults can some from; # - a file we ship with the packages # - a customly supplied file (by customer) # - a file we write out # - this python class # # Look, we want defaults self.defaults = Defaults(self.plugins) # But, they should be available in our class as well for option in self.defaults.__dict__: log.debug( _("Setting %s to %r (from defaults)") % ( option, self.defaults.__dict__[option] ), level=8 ) setattr(self, option, self.defaults.__dict__[option]) # This is where we check our parser for the defaults being set there. self.set_defaults_from_cli_options() self.options_set_from_config() # Also set the cli options if hasattr(self, 'cli_keywords') and self.cli_keywords is not None: for option in self.cli_keywords.__dict__: retval = False if hasattr(self, "check_setting_%s" % (option)): exec( "retval = self.check_setting_%s(%r)" % ( option, self.cli_keywords.__dict__[option] ) ) # The warning, error or confirmation dialog is in the check_setting_%s() # function if not retval: continue log.debug( _("Setting %s to %r (from CLI, verified)") % ( option, self.cli_keywords.__dict__[option] ), level=8 ) setattr(self, option, self.cli_keywords.__dict__[option]) else: log.debug( _("Setting %s to %r (from CLI, not checked)") % ( option, self.cli_keywords.__dict__[option] ), level=8 ) setattr(self, option, self.cli_keywords.__dict__[option]) def load_config(self, config): """ Given a SafeConfigParser instance, loads a configuration file and checks, then sets everything it can find. """ for section in self.defaults.__dict__: if section == 'testing': continue if not config.has_section(section): continue for key in self.defaults.__dict__[section]: retval = False if not config.has_option(section, key): continue if isinstance(self.defaults.__dict__[section][key], int): value = config.getint(section, key) elif isinstance(self.defaults.__dict__[section][key], bool): value = config.getboolean(section, key) elif isinstance(self.defaults.__dict__[section][key], str): value = config.get(section, key) elif isinstance(self.defaults.__dict__[section][key], list): value = eval(config.get(section, key)) elif isinstance(self.defaults.__dict__[section][key], dict): value = eval(config.get(section, key)) if hasattr(self, "check_setting_%s_%s" % (section, key)): exec("retval = self.check_setting_%s_%s(%r)" % (section, key, value)) if not retval: # We just don't set it, check_setting_%s should have # taken care of the error messages continue if not self.defaults.__dict__[section][key] == value: if key.count('password') >= 1: log.debug( _("Setting %s_%s to '****' (from configuration file)") % ( section, key ), level=8 ) else: log.debug( _("Setting %s_%s to %r (from configuration file)") % ( section, key, value ), level=8 ) setattr(self, "%s_%s" % (section, key), value) def options_set_from_config(self): """ Sets the default configuration options from a configuration file. Configuration file may be customized using the --config CLI option """ log.debug(_("Setting options from configuration file"), level=4) # Check from which configuration file we should get the defaults # Other then default? self.config_file = self.defaults.config_file if hasattr(self, 'cli_keywords') and self.cli_keywords is not None: if not self.cli_keywords.config_file == self.defaults.config_file: self.config_file = self.cli_keywords.config_file config = self.check_config() self.load_config(config) def set_options_from_testing_section(self): """ Go through the options in the [testing] section if it exists. """ config = self.check_config() if not config.has_section('testing'): return for key in config.options('testing'): retval = False if isinstance(self.defaults.__dict__['testing'][key], int): value = config.getint('testing', key) elif isinstance(self.defaults.__dict__['testing'][key], bool): value = config.getboolean('testing', key) elif isinstance(self.defaults.__dict__['testing'][key], str): value = config.get('testing', key) elif isinstance(self.defaults.__dict__['testing'][key], list): value = eval(config.get('testing', key)) elif isinstance(self.defaults.__dict__['testing'][key], dict): value = eval(config.get('testing', key)) if hasattr(self, "check_setting_%s_%s" % ('testing', key)): exec("retval = self.check_setting_%s_%s(%r)" % ('testing', key, value)) if not retval: # We just don't set it, check_setting_%s should have # taken care of the error messages continue setattr(self, "%s_%s" % ('testing', key), value) if key.count('password') >= 1: log.debug( _("Setting %s_%s to '****' (from configuration file)") % ('testing', key), level=8 ) else: log.debug( _("Setting %s_%s to %r (from configuration file)") % ('testing', key, value), level=8 ) def check_config(self, val=None): """ Checks self.config_file or the filename passed using 'val' and returns a SafeConfigParser instance if everything is OK. """ if val is not None: config_file = val else: config_file = self.config_file if not os.access(config_file, os.R_OK): log.error(_("Configuration file %s not readable") % config_file) config = SafeConfigParser() log.debug(_("Reading configuration file %s") % config_file, level=8) try: config.read(config_file) except Exception: log.error(_("Invalid configuration file %s") % config_file) if not config.has_section("kolab"): log.warning( _("No master configuration section [kolab] in configuration file %s") % config_file ) return config def add_cli_parser_option_group(self, name): return self.cli_parser.add_option_group(name) def create_options_from_plugins(self): """ Create options from plugins. This function must be called separately from Conf.__init__(), or the configuration store is not yet done initializing when the plugins class and the plugins themselves go look for it. """ import pykolab.plugins self.plugins = pykolab.plugins.KolabPlugins() self.plugins.add_options(self.cli_parser) def create_options(self, load_plugins=True): """ Create the OptionParser for the options passed to us from runtime Command Line Interface. """ # Enterprise Linux 5 does not have an "epilog" parameter to OptionParser try: self.cli_parser = OptionParser(epilog=epilog) except Exception: self.cli_parser = OptionParser() # # Runtime Options # runtime_group = self.cli_parser.add_option_group(_("Runtime Options")) runtime_group.add_option( "-c", "--config", dest="config_file", action="store", default="/etc/kolab/kolab.conf", help=_("Configuration file to use") ) runtime_group.add_option( "-d", "--debug", dest="debuglevel", type='int', default=0, help=_( "Set the debugging verbosity. Maximum is 9, tracing protocols LDAP, SQL and IMAP." ) ) runtime_group.add_option( "-e", "--default", dest="answer_default", action="store_true", default=False, help=_("Use the default answer to all questions.") ) runtime_group.add_option( "-l", dest="loglevel", type='str', default="CRITICAL", help=_("Set the logging level. One of info, warn, error, critical or debug") ) runtime_group.add_option( "--logfile", dest="logfile", action="store", default="/var/log/kolab/pykolab.log", help=_("Log file to use") ) runtime_group.add_option( "-q", "--quiet", dest="quiet", action="store_true", default=False, help=_("Be quiet.") ) runtime_group.add_option( "-y", "--yes", dest="answer_yes", action="store_true", default=False, help=_("Answer yes to all questions.") ) def parse_options(self, fatal=True): """ Parse options passed to our call. """ if fatal: (self.cli_keywords, self.cli_args) = self.cli_parser.parse_args() def run(self): """ Run Forest, RUN! """ if self.cli_args: if len(self.cli_args) >= 1: if hasattr(self, "command_%s" % self.cli_args[0].replace('-', '_')): exec( "self.command_%s(%r)" % ( self.cli_args[0].replace('-', '_'), self.cli_args[1:] ) ) else: print(_("No command supplied"), file=sys.stderr) def command_dump(self, *args, **kw): """ Dumps applicable, valid configuration that is not defaults. """ if not self.cfg_parser: self.read_config() if not self.cfg_parser.has_section('kolab'): print("No section found for kolab", file=sys.stderr) sys.exit(1) # Get the sections, and then walk through the sections in a # sensible way. items = self.cfg_parser.options('kolab') items.sort() for item in items: mode = self.cfg_parser.get('kolab', item) print("%s = %s" % (item, mode)) if not self.cfg_parser.has_section(mode): print("WARNING: No configuration section %s for item %s" % (mode, item)) continue keys = self.cfg_parser.options(mode) keys.sort() if self.cfg_parser.has_option(mode, 'leave_this_one_to_me'): print("Ignoring section %s" % (mode)) continue for key in keys: print("%s_%s = %s" % (mode, key, self.cfg_parser.get(mode, key))) def read_config(self, value=None): """ Reads the configuration file, sets a self.cfg_parser. """ if not value: value = self.defaults.config_file if hasattr(self, 'cli_keywords') and self.cli_keywords is not None: value = self.cli_keywords.config_file self.cfg_parser = SafeConfigParser() self.cfg_parser.read(value) if hasattr(self, 'cli_keywords') and hasattr(self.cli_keywords, 'config_file'): self.cli_keywords.config_file = value self.defaults.config_file = value self.config_file = value def command_get(self, *args, **kw): """ Get a configuration option. Pass me a section and key please. """ exec("args = %r" % args) print("%s/%s: %r" % (args[0], args[1], self.get(args[0], args[1]))) # if len(args) == 3: # # Return non-zero if no match # # Return zero if match # # Improvised "check" function def command_set(self, *args, **kw): """ Set a configuration option. Pass me a section, key and value please. Note that the section should already exist. TODO: Add a strict parameter TODO: Add key value checking """ if not self.cfg_parser: self.read_config() if not len(args) == 3: log.error(_("Insufficient options. Need section, key and value -in that order.")) if not self.cfg_parser.has_section(args[0]): log.error(_("No section '%s' exists.") % (args[0])) if '%' in args[2]: value = args[2].replace('%', '%%') else: value = args[2] self.cfg_parser.set(args[0], args[1], value) if hasattr(self, 'cli_keywords') and hasattr(self.cli_keywords, 'config_file'): fp = open(self.cli_keywords.config_file, "w+") self.cfg_parser.write(fp) fp.close() else: fp = open(self.config_file, "w+") self.cfg_parser.write(fp) fp.close() def create_logger(self): """ Create a logger instance using cli_options.debuglevel """ global log if self.cli_keywords.debuglevel is not None: loglevel = logging.DEBUG else: loglevel = logging.INFO self.cli_keywords.debuglevel = 0 self.debuglevel = self.cli_keywords.debuglevel # Initialize logger log = pykolab.logger.Logger( loglevel=loglevel, debuglevel=self.cli_keywords.debuglevel, logfile=self.cli_keywords.logfile ) def set_defaults_from_cli_options(self): for long_opt in self.cli_parser.__dict__['_long_opt']: if long_opt == "--help": continue setattr( self.defaults, self.cli_parser._long_opt[long_opt].dest, self.cli_parser._long_opt[long_opt].default ) # But, they should be available in our class as well for option in self.cli_parser.defaults: log.debug( _("Setting %s to %r (from the default values for CLI options)") % ( option, self.cli_parser.defaults[option] ), level=8 ) setattr(self, option, self.cli_parser.defaults[option]) def has_section(self, section): if not self.cfg_parser: self.read_config() return self.cfg_parser.has_section(section) def has_option(self, section, option): if not self.cfg_parser: self.read_config() return self.cfg_parser.has_option(section, option) def get_list(self, section, key, default=None): """ Gets a comma and/or space separated list from the configuration file and returns a list. """ values = [] untrimmed_values = [] setting = self.get_raw(section, key) if setting is None: return default if default else [] raw_values = setting.split(',') if raw_values is None: return default if default else [] for raw_value in raw_values: untrimmed_values.extend(raw_value.split(' ')) for value in untrimmed_values: if not value.strip() == "": values.append(value.strip().lower()) return values def get_raw(self, section, key, default=None): if not self.cfg_parser: self.read_config() if self.cfg_parser.has_option(section, key): return self.cfg_parser.get(section, key, 1) return default def get(self, section, key, default=None, quiet=False): """ Get a configuration option from our store, the configuration file, or an external source if we have some sort of function for it. TODO: Include getting the value from plugins through a hook. """ retval = False if not self.cfg_parser: self.read_config() # log.debug(_("Obtaining value for section %r, key %r") % (section, key), level=8) if self.cfg_parser.has_option(section, key): try: return self.cfg_parser.get(section, key) except Exception: self.read_config() return self.cfg_parser.get(section, key) if hasattr(self, "get_%s_%s" % (section, key)): try: exec("retval = self.get_%s_%s(quiet)" % (section, key)) except Exception: log.error( _("Could not execute configuration function: %s") % ( "get_%s_%s(quiet=%r)" % ( section, key, quiet ) ) ) return default return retval if quiet: return "" else: log.debug( _("Option %s/%s does not exist in config file %s, pulling from defaults") % ( section, key, self.config_file ), level=8 ) if hasattr(self.defaults, "%s_%s" % (section, key)): return getattr(self.defaults, "%s_%s" % (section, key)) elif hasattr(self.defaults, "%s" % (section)): if key in getattr(self.defaults, "%s" % (section)): _dict = getattr(self.defaults, "%s" % (section)) return _dict[key] else: log.warning(_("Option does not exist in defaults.")) return default else: log.warning(_("Option does not exist in defaults.")) return default def check_setting_config_file(self, value): if os.path.isfile(value): if os.access(value, os.R_OK): self.read_config(value=value) self.config_file = value return True else: log.error(_("Configuration file %s not readable.") % (value)) return False else: log.error(_("Configuration file %s does not exist.") % (value)) return False def check_setting_debuglevel(self, value): if value < 0: log.info( _( "WARNING: A negative debug level value does not " + "make this program be any more silent." ) ) elif value == 0: return True elif value <= 9: return True else: log.warning(_("This program has 9 levels of verbosity. Using the maximum of 9.")) return True def check_setting_saslauth_mode(self, value): if value: # TODO: I suppose this is platform specific if os.path.isfile("/var/run/saslauthd/mux"): if os.path.isfile("/var/run/saslauthd/saslauthd.pid"): log.error(_("Cannot start SASL authentication daemon")) return False else: try: os.remove("/var/run/saslauthd/mux") except IOError: log.error(_("Cannot start SASL authentication daemon")) return False elif os.path.isfile("/var/run/sasl2/mux"): if os.path.isfile("/var/run/sasl2/saslauthd.pid"): log.error(_("Cannot start SASL authentication daemon")) return False else: try: os.remove("/var/run/sasl2/mux") except IOError: log.error(_("Cannot start SASL authentication daemon")) return False return True def check_setting_use_imap(self, value): if value: try: import imaplib self.use_imap = value return True except ImportError: log.error(_("No imaplib library found.")) return False def check_setting_use_lmtp(self, value): if value: try: from smtplib import LMTP self.use_lmtp = value return True except ImportError: log.error(_("No LMTP class found in the smtplib library.")) return False def check_setting_use_mail(self, value): if value: try: from smtplib import SMTP self.use_mail = value return True except ImportError: log.error(_("No SMTP class found in the smtplib library.")) return False def check_setting_test_suites(self, value): # Attempt to load the suite, # Get the suite's options, # Set them here. if not hasattr(self, 'test_suites'): self.test_suites = [] if "zpush" in value: selectively = False for item in ['calendar', 'contacts', 'mail']: if self.cli_keywords.__dict__[item]: log.debug( _("Found you specified a specific set of items to test: %s") % (item), level=8 ) selectively = item if not selectively: self.calendar = True self.contacts = True self.mail = True else: log.debug(_("Selectively selecting: %s") % (selectively), level=8) setattr(self, selectively, True) self.test_suites.append('zpush') def check_setting_calendar(self, value): if self.cli_parser._long_opt['--calendar'].default == value: return False else: return True def check_setting_contacts(self, value): if self.cli_parser._long_opt['--contacts'].default == value: return False else: return True def check_setting_mail(self, value): if self.cli_parser._long_opt['--mail'].default == value: return False else: return True diff --git a/pykolab/conf/entitlement.py b/pykolab/conf/entitlement.py index 3dd92d6..ac2b169 100644 --- a/pykolab/conf/entitlement.py +++ b/pykolab/conf/entitlement.py @@ -1,266 +1,269 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from ConfigParser import ConfigParser +try: + from ConfigParser import ConfigParser +except ImportError: + from configparser import ConfigParser import hashlib import OpenSSL import os import StringIO import subprocess import sys from pykolab.translate import _ import pykolab log = pykolab.getLogger('pykolab.conf') class Entitlement(object): def __init__(self, *args, **kw): self.entitlement = {} self.entitlement_files = [] ca_cert_file = '/etc/pki/tls/certs/mirror.kolabsys.com.ca.cert' customer_cert_file = '/etc/pki/tls/private/mirror.kolabsys.com.client.pem' customer_key_file = '/etc/pki/tls/private/mirror.kolabsys.com.client.pem' # Licence lock and key verification. self.entitlement_verification = [ 'f700660f456a60c92ab2f00d0f1968230920d89829d42aa27d30f678', '95783ba5521ea54aa3a32b7949f145aa5015a4c9e92d12b9e4c95c14' ] if os.access(ca_cert_file, os.R_OK): # Verify /etc/kolab/mirror_ca.crt ca_cert = OpenSSL.crypto.load_certificate( OpenSSL.SSL.FILETYPE_PEM, open(ca_cert_file).read() ) if (bool)(ca_cert.has_expired()): raise Exception(_("Invalid entitlement verification " + \ "certificate at %s" % (ca_cert_file))) # TODO: Check validity and warn ~1-2 months in advance. ca_cert_issuer = ca_cert.get_issuer() ca_cert_subject = ca_cert.get_subject() ca_cert_issuer_hash = subprocess.Popen( [ 'openssl', 'x509', '-in', ca_cert_file, '-noout', '-issuer_hash' ], stdout=subprocess.PIPE ).communicate()[0].strip() ca_cert_issuer_hash_digest = hashlib.sha224(ca_cert_issuer_hash).hexdigest() if not ca_cert_issuer_hash_digest in self.entitlement_verification: raise Exception(_("Invalid entitlement verification " + \ "certificate at %s") % (ca_cert_file)) ca_cert_subject_hash = subprocess.Popen( [ 'openssl', 'x509', '-in', ca_cert_file, '-noout', '-subject_hash' ], stdout=subprocess.PIPE ).communicate()[0].strip() ca_cert_subject_hash_digest = hashlib.sha224(ca_cert_subject_hash).hexdigest() if not ca_cert_subject_hash_digest in self.entitlement_verification: raise Exception(_("Invalid entitlement verification " + \ "certificate at %s") % (ca_cert_file)) customer_cert_issuer_hash = subprocess.Popen( [ 'openssl', 'x509', '-in', customer_cert_file, '-noout', '-issuer_hash' ], stdout=subprocess.PIPE ).communicate()[0].strip() customer_cert_issuer_hash_digest = hashlib.sha224(customer_cert_issuer_hash).hexdigest() if not customer_cert_issuer_hash_digest in self.entitlement_verification: raise Exception(_("Invalid entitlement verification " + \ "certificate at %s") % (customer_cert_file)) if not ca_cert_issuer.countryName == ca_cert_subject.countryName: raise Exception(_("Invalid entitlement certificate")) if not ca_cert_issuer.organizationName == ca_cert_subject.organizationName: raise Exception(_("Invalid entitlement certificate")) if os.path.isdir('/etc/kolab/entitlement.d/') and \ os.access('/etc/kolab/entitlement.d/', os.R_OK): for root, dirs, files in os.walk('/etc/kolab/entitlement.d/'): if not root == '/etc/kolab/entitlement.d/': continue for entitlement_file in files: log.debug(_("Parsing entitlement file %s") % (entitlement_file), level=8) if os.access(os.path.join(root, entitlement_file), os.R_OK): self.entitlement_files.append( os.path.join(root, entitlement_file) ) else: log.error( _("License file %s not readable!") % ( os.path.join(root, entitlement_file) ) ) else: log.error(_("No entitlement directory found")) for entitlement_file in self.entitlement_files: decrypt_command = [ 'openssl', 'smime', '-decrypt', '-recip', customer_cert_file, '-in', entitlement_file ] decrypt_process = subprocess.Popen( decrypt_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) verify_command = [ 'openssl', 'smime', '-verify', '-certfile', ca_cert_file, '-CAfile', ca_cert_file, '-inform', 'DER' ] verify_process = subprocess.Popen( verify_command, stdin=decrypt_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) (stdout, stderr) = verify_process.communicate() license = License(stdout, self.entitlement) license.verify_certificate(customer_cert_file) self.entitlement = license.get() # else: # log.error(_("Error reading entitlement certificate authority file")) def get(self): if len(self.entitlement) == 0: return None else: return self.entitlement class License(object): entitlement = {} def __init__(self, new_entitlement, existing_entitlement): self.parser = ConfigParser() fp = StringIO.StringIO(new_entitlement) self.parser.readfp(fp) self.entitlement['users'] = self.parser.get('kolab_entitlements', 'users') self.entitlement['margin'] = self.parser.get('kolab_entitlements', 'margin') def verify_certificate(self, customer_cert_file): # Verify the certificate section as well. cert_serial = self.parser.get('mirror_ca', 'serial_number') cert_issuer_hash = self.parser.get('mirror_ca', 'issuer_hash') cert_subject_hash = self.parser.get('mirror_ca', 'subject_hash') customer_cert_serial = subprocess.Popen( [ 'openssl', 'x509', '-in', customer_cert_file, '-noout', '-serial' ], stdout=subprocess.PIPE ).communicate()[0].strip().split('=')[1] if not customer_cert_serial == cert_serial: raise Exception(_("Invalid entitlement verification " + \ "certificate at %s") % (customer_cert_file)) customer_cert_issuer_hash = subprocess.Popen( [ 'openssl', 'x509', '-in', customer_cert_file, '-noout', '-issuer_hash' ], stdout=subprocess.PIPE ).communicate()[0].strip() if not customer_cert_issuer_hash == cert_issuer_hash: raise Exception(_("Invalid entitlement verification " + \ "certificate at %s") % (customer_cert_file)) customer_cert_subject_hash = subprocess.Popen( [ 'openssl', 'x509', '-in', customer_cert_file, '-noout', '-subject_hash' ], stdout=subprocess.PIPE ).communicate()[0].strip() if not customer_cert_subject_hash == cert_subject_hash: raise Exception(_("Invalid entitlement verification " + \ "certificate at %s") % (customer_cert_file)) def get(self): return self.entitlement diff --git a/pykolab/imap/__init__.py b/pykolab/imap/__init__.py index a54345c..9cea0d9 100644 --- a/pykolab/imap/__init__.py +++ b/pykolab/imap/__init__.py @@ -1,1258 +1,1261 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import logging import re import time import socket import sys -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import pykolab from pykolab import utils from pykolab.translate import _ log = pykolab.getLogger('pykolab.imap') conf = pykolab.getConf() class IMAP(object): def __init__(self): # Pool of named IMAP connections, by hostname self._imap = {} # Place holder for the current IMAP connection self.imap = None def cleanup_acls(self, aci_subject): log.info( _("Cleaning up ACL entries for %s across all folders") % ( aci_subject ) ) lm_suffix = "" if len(aci_subject.split('@')) > 1: lm_suffix = "@%s" % (aci_subject.split('@')[1]) shared_folders = self.imap.lm("shared/*%s" % (lm_suffix)) user_folders = self.imap.lm("user/*%s" % (lm_suffix)) # For all folders (shared and user), ... folders = user_folders + shared_folders log.debug(_("Iterating over %d folders") % (len(folders)), level=5) # ... loop through them and ... for folder in folders: try: # ... list the ACL entries acls = self.imap.lam(folder) # For each ACL entry, see if we think it is a current, valid # entry for acl_entry in acls: # If the key 'acl_entry' does not exist in the dictionary # of valid ACL entries, this ACL entry has got to go. if acl_entry == aci_subject: # Set the ACL to '' (effectively deleting the ACL # entry) log.debug( _( "Removing acl %r for subject %r from folder %r" ) % ( acls[acl_entry], acl_entry, folder ), level=8 ) self.set_acl(folder, acl_entry, '') except Exception as errmsg: log.error( _("Failed to read/set ACL on folder %s: %r") % ( folder, errmsg ) ) def connect(self, uri=None, server=None, domain=None, login=True): """ Connect to the appropriate IMAP backend. Supply a domain (name space) configured in the configuration file as a section, with a setting 'imap_uri' to connect to a domain specific IMAP server, or specify an URI to connect to that particular IMAP server (in that order). Routines sitting behind this will take into account Cyrus IMAP Murder capabilities, brokering actions to take place against the correct server (such as a 'xfer' which needs to happen against the source backend). """ # TODO: We are currently compatible with one IMAP backend technology per # deployment. backend = conf.get('kolab', 'imap_backend') if domain is not None: self.domain = domain if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if uri is None: if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: self.domain = None scheme = None hostname = None port = None if uri is None: uri = conf.get(backend, 'uri') result = urlparse(uri) if hasattr(result, 'netloc'): scheme = result.scheme if len(result.netloc.split(':')) > 1: hostname = result.netloc.split(':')[0] port = result.netloc.split(':')[1] else: hostname = result.netloc elif hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if server is not None: hostname = server if scheme is None or scheme == "": scheme = 'imaps' if port is None: if scheme == "imaps": port = 993 elif scheme == "imap": port = 143 else: port = 993 uri = '%s://%s:%s' % (scheme, hostname, port) # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') if admin_password is None or admin_password == '': log.error(_("No administrator password is available.")) if hostname not in self._imap: if backend == 'cyrus-imap': import cyrus self._imap[hostname] = cyrus.Cyrus(uri) # Actually connect if login: log.debug(_("Logging on to Cyrus IMAP server %s") % (hostname), level=8) self._imap[hostname].login(admin_login, admin_password) self._imap[hostname].logged_in = True elif backend == 'dovecot': import dovecot self._imap[hostname] = dovecot.Dovecot(uri) # Actually connect if login: log.debug(_("Logging on to Dovecot IMAP server %s") % (hostname), level=8) self._imap[hostname].login(admin_login, admin_password) self._imap[hostname].logged_in = True else: import imaplib self._imap[hostname] = imaplib.IMAP4(hostname, port) # Actually connect if login: log.debug(_("Logging on to generic IMAP server %s") % (hostname), level=8) self._imap[hostname].login(admin_login, admin_password) self._imap[hostname].logged_in = True else: if not login: self.disconnect(hostname) self.connect(uri=uri, login=False) elif login and not hasattr(self._imap[hostname], 'logged_in'): self.disconnect(hostname) self.connect(uri=uri) else: try: if hasattr(self._imap[hostname], 'm'): self._imap[hostname].m.noop() elif hasattr(self._imap[hostname], 'noop') \ and callable(self._imap[hostname].noop): self._imap[hostname].noop() log.debug( _("Reusing existing IMAP server connection to %s") % (hostname), level=8 ) except Exception: log.debug(_("Reconnecting to IMAP server %s") % (hostname), level=8) self.disconnect(hostname) self.connect() # Set the newly created technology specific IMAP library as the current # IMAP connection to be used. self.imap = self._imap[hostname] if hasattr(self.imap, 'm') and hasattr(self.imap.m, 'sock'): self._set_socket_keepalive(self.imap.m.sock) elif hasattr(self.imap, 'sock'): self._set_socket_keepalive(self.imap.sock) def disconnect(self, server=None): if server is None: # No server specified, but make sure self.imap is None anyways if hasattr(self, 'imap'): del self.imap # Empty out self._imap as well for key in list(self._imap): del self._imap[key] else: if server in self._imap: del self._imap[server] else: log.warning( _("Called imap.disconnect() on a server that we had no connection to.") ) def create_folder(self, folder_path, server=None, partition=None): folder_path = self.folder_utf7(folder_path) if server is not None: self.connect(server=server) try: self._imap[server].cm(folder_path, partition=partition) return True except Exception as excpt: log.error( _("Could not create folder %r on server %r: %r") % (folder_path, server, excpt) ) else: try: self.imap.cm(folder_path, partition=partition) return True except Exception as excpt: log.error(_("Could not create folder %r: %r") % (folder_path, excpt)) return False def __getattr__(self, name): if hasattr(self.imap, name): return getattr(self.imap, name) if hasattr(self.imap, 'm'): if hasattr(self.imap.m, name): return getattr(self.imap.m, name) raise AttributeError(_("%r has no attribute %s") % (self, name)) def append(self, folder, message): return self.imap.m.append(self.folder_utf7(folder), None, None, message) def folder_utf7(self, folder): from pykolab import imap_utf7 return imap_utf7.encode(folder) def folder_utf8(self, folder): from pykolab import imap_utf7 return imap_utf7.decode(folder) def folder_quote(self, folder): return u'"' + str(folder).strip('"') + '"' def get_metadata(self, folder): """ Obtain all metadata entries on a folder """ metadata = {} _metadata = self.imap.getannotation(self.folder_utf7(folder), '*') for (k, v) in _metadata.items(): metadata[self.folder_utf8(k)] = v return metadata def get_separator(self): if not hasattr(self, 'imap') or self.imap is None: self.connect() if hasattr(self.imap, 'separator'): return self.imap.separator elif hasattr(self.imap, 'm') and hasattr(self.imap.m, 'separator'): return self.imap.m.separator else: return '/' def imap_murder(self): if hasattr(self.imap, 'murder') and self.imap.murder: return True else: return False def namespaces(self): """ Obtain the namespaces. Returns a tuple of: (str(personal) [, str(other users) [, list(shared)]]) """ _personal = None _other_users = None _shared = None (_response, _namespaces) = self.imap.m.namespace() if len(_namespaces) == 1: _namespaces = _namespaces[0] _namespaces = re.split(r"\)\)\s\(\(", _namespaces) if len(_namespaces) >= 3: _shared = [] _shared.append(' '.join(_namespaces[2].replace('((', '').replace('))', '').split()[:-1]).replace('"', '')) if len(_namespaces) >= 2: _other_users = ' '.join(_namespaces[1].replace('((', '').replace('))', '').split()[:-1]).replace('"', '') if len(_namespaces) >= 1: _personal = _namespaces[0].replace('((', '').replace('))', '').split()[0].replace('"', '') return (_personal, _other_users, _shared) def set_acl(self, folder, identifier, acl): """ Set an ACL entry on a folder. """ _acl = '' short_rights = { 'all': 'lrsedntxakcpiw', 'append': 'wip', 'full': 'lrswipkxtecdn', 'read': 'lrs', 'read-only': 'lrs', 'read-write': 'lrswitedn', 'post': 'p', 'semi-full': 'lrswit', 'write': 'lrswite', } if acl in short_rights: acl = short_rights[acl] else: for char in acl: if char in "-+": continue if not char in short_rights['all']: log.error(_("Invalid access identifier %r for subject %r") % (acl, identifier)) return False # Special treatment for '-' and '+' characters if '+' in acl or '-' in acl: acl_map = { 'set': '', 'subtract': '', 'add': '' } mode = 'set' for char in acl: if char == '-': mode = 'subtract' continue if char == '+': mode = 'add' continue acl_map[mode] += char current_acls = self.imap.lam(self.folder_utf7(folder)) for current_acl in current_acls: if current_acl == identifier: _acl = current_acls[current_acl] break _acl = _acl + acl_map['set'] + acl_map['add'] _acl = [x for x in _acl.split() if x not in acl_map['subtract'].split()] acl = ''.join(list(set(_acl))) try: self.imap.sam(self.folder_utf7(folder), identifier, acl) except Exception as errmsg: log.error( _("Could not set ACL for %s on folder %s: %r") % ( identifier, folder, errmsg ) ) def set_metadata(self, folder, metadata_path, metadata_value, shared=True): """ Set a metadata entry on a folder """ if metadata_path.startswith('/shared/'): shared = True metadata_path = metadata_path.replace('/shared/', '/') elif metadata_path.startswith('/private/'): shared = False metadata_path = metadata_path.replace('/private/', '/') self.imap._setannotation(self.folder_utf7(folder), metadata_path, metadata_value, shared) def shared_folder_create(self, folder_path, server=None): """ Create a shared folder. """ folder_name = "shared%s%s" % (self.get_separator(), folder_path) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] log.info(_("Creating new shared folder %s") % (folder_name)) self.create_folder(folder_name, server) def shared_folder_exists(self, folder_path): """ Check if a shared mailbox exists. """ folder_name = 'shared%s%s' % (self.get_separator(), folder_path) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] return self.has_folder(folder_name) def shared_folder_rename(self, old, new): if not self.has_folder(old): log.error("Shared Folder rename error: Folder %s does not exist" % (old)) return if self.has_folder(new): log.error("Shared Folder rename error: Folder %s already exists" % (new)) return self.imap._rename(old, new) def shared_folder_set_type(self, folder_path, folder_type): folder_name = 'shared%s%s' % (self.get_separator(), folder_path) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] self.set_metadata(folder_name, '/shared/vendor/kolab/folder-type', folder_type) def shared_mailbox_create(self, mailbox_base_name, server=None): """ Create a shared folder. """ self.connect() folder_name = "shared%s%s" % (self.get_separator(), mailbox_base_name) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] log.info(_("Creating new shared folder %s") %(mailbox_base_name)) self.create_folder(folder_name, server) def shared_mailbox_exists(self, mailbox_base_name): """ Check if a shared mailbox exists. """ self.connect() folder_name = "shared%s%s" % (self.get_separator(), mailbox_base_name) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] return self.has_folder(folder_name) def user_mailbox_create(self, mailbox_base_name, server=None): """ Create a user mailbox. Returns the full path to the new mailbox folder. """ # TODO: Whether or not to lowercase the mailbox name is really up to the # IMAP server setting username_tolower (normalize_uid, lmtp_downcase_rcpt). self.connect() if not mailbox_base_name == mailbox_base_name.lower(): log.warning(_("Downcasing mailbox name %r") % (mailbox_base_name)) mailbox_base_name = mailbox_base_name.lower() folder_name = "user%s%s" % (self.get_separator(), mailbox_base_name) log.info(_("Creating new mailbox for user %s") %(mailbox_base_name)) success = self._create_folder_waiting(folder_name, server) if not success: log.error(_("Could not create the mailbox for user %s, aborting." % (mailbox_base_name))) return False _additional_folders = None if not hasattr(self, 'domain'): self.domain = None if self.domain is None and len(mailbox_base_name.split('@')) > 1: self.domain = mailbox_base_name.split('@')[1] if not self.domain is None: if conf.has_option(self.domain, "autocreate_folders"): _additional_folders = conf.get_raw( self.domain, "autocreate_folders" ) else: from pykolab.auth import Auth auth = Auth() auth.connect() domains = auth.list_domains(self.domain) auth.disconnect() if len(domains) > 0: if self.domain in domains: primary = domains[self.domain] if conf.has_option(primary, "autocreate_folders"): _additional_folders = conf.get_raw( primary, "autocreate_folders" ) if _additional_folders is None: if conf.has_option('kolab', "autocreate_folders"): _additional_folders = conf.get_raw( 'kolab', "autocreate_folders" ) additional_folders = conf.plugins.exec_hook( "create_user_folders", kw={ 'folder': folder_name, 'additional_folders': _additional_folders } ) if additional_folders is not None: self.user_mailbox_create_additional_folders( mailbox_base_name, additional_folders ) if not self.domain is None: if conf.has_option(self.domain, "sieve_mgmt"): sieve_mgmt_enabled = conf.get(self.domain, 'sieve_mgmt') if utils.true_or_false(sieve_mgmt_enabled): conf.plugins.exec_hook( 'sieve_mgmt_refresh', kw={ 'user': mailbox_base_name } ) return folder_name def user_mailbox_create_additional_folders(self, user, additional_folders): log.debug( _("Creating additional folders for user %s") % (user), level=8 ) backend = conf.get('kolab', 'imap_backend') admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') folder = 'user%s%s' % (self.get_separator(), user) if self.imap_murder(): server = self.user_mailbox_server(folder) else: server = None success = False last_log = time.time() while not success: try: self.disconnect() self.connect(login=False, server=server) self.login_plain(admin_login, admin_password, user) (personal, other, shared) = self.namespaces() success = True except Exception as errmsg: if time.time() - last_log > 5 and self.imap_murder(): log.debug(_("Waiting for the Cyrus murder to settle... %r") % (errmsg)) last_log = time.time() if conf.debuglevel > 8: import traceback traceback.print_exc() time.sleep(0.5) for additional_folder in additional_folders: _add_folder = {} folder_name = additional_folder if not folder_name.startswith(personal): log.error(_("Correcting additional folder name from %r to %r") % (folder_name, "%s%s" % (personal, folder_name))) folder_name = "%s%s" % (personal, folder_name) success = self._create_folder_waiting(folder_name) if not success: log.warning(_("Failed to create folder: %s") % (folder_name)) continue if "annotations" in additional_folders[additional_folder]: for annotation in additional_folders[additional_folder]["annotations"]: self.set_metadata( folder_name, "%s" % (annotation), "%s" % (additional_folders[additional_folder]["annotations"][annotation]) ) if "acls" in additional_folders[additional_folder]: for acl in additional_folders[additional_folder]["acls"]: self.set_acl( folder_name, "%s" % (acl), "%s" % (additional_folders[additional_folder]["acls"][acl]) ) if len(user.split('@')) > 1: localpart = user.split('@')[0] domain = user.split('@')[1] domain_suffix = "@%s" % (domain) else: localpart = user domain = None domain_suffix = "" if domain is not None: if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: uri = None log.debug(_("Subscribing user to the additional folders"), level=8) _tests = [] # Subscribe only to personal folders (personal, other, shared) = self.namespaces() if other is not None: _tests.append(other) if shared is not None: for _shared in shared: _tests.append(_shared) log.debug(_("Using the following tests for folder subscriptions:"), level=8) for _test in _tests: log.debug(_(" %r") % (_test), level=8) for _folder in self.lm(): log.debug(_("Folder %s") % (_folder), level=8) _subscribe = True for _test in _tests: if not _subscribe: continue if _folder.startswith(_test): _subscribe = False if _subscribe: log.debug(_("Subscribing %s to folder %s") % (user, _folder), level=8) try: self.subscribe(_folder) except Exception as errmsg: log.error(_("Subscribing %s to folder %s failed: %r") % (user, _folder, errmsg)) self.logout() self.connect(domain=self.domain) for additional_folder in additional_folders: if additional_folder.startswith(personal) and not personal == '': folder_name = additional_folder.replace(personal, '') else: folder_name = additional_folder folder_name = "user%s%s%s%s%s" % ( self.get_separator(), localpart, self.get_separator(), folder_name, domain_suffix ) if "quota" in additional_folders[additional_folder]: try: self.imap.sq( folder_name, additional_folders[additional_folder]['quota'] ) except Exception as errmsg: log.error(_("Could not set quota on %s") % (additional_folder)) if "partition" in additional_folders[additional_folder]: partition = additional_folders[additional_folder]["partition"] try: self.imap._rename(folder_name, folder_name, partition) except: log.error(_("Could not rename %s to reside on partition %s") % (folder_name, partition)) def _create_folder_waiting(self, folder_name, server=None): """ Create a folder and wait to make sure it exists """ created = False try: max_tries = 10 while not created and max_tries > 0: created = self.create_folder(folder_name, server) if not created: self.disconnect() max_tries -= 1 time.sleep(1) self.connect() # In a Cyrus IMAP Murder topology, wait for the murder to have settled if created and self.imap_murder(): success = False last_log = time.time() reconnect_counter = 0 while not success: success = self.has_folder(folder_name) if not success: if time.time() - last_log > 5: reconnect_counter += 1 log.info(_("Waiting for the Cyrus IMAP Murder to settle...")) if reconnect_counter == 6: log.warning(_("Waited for 15 seconds, going to reconnect")) reconnect_counter = 0 self.disconnect() self.connect() last_log = time.time() time.sleep(0.5) except: if conf.debuglevel > 8: import traceback traceback.print_exc() return created def user_mailbox_delete(self, mailbox_base_name): """ Delete a user mailbox. """ self.connect() folder = "user%s%s" %(self.get_separator(), mailbox_base_name) self.delete_mailfolder(folder) self.cleanup_acls(mailbox_base_name) def user_mailbox_exists(self, mailbox_base_name): """ Check if a user mailbox exists. """ self.connect() if not mailbox_base_name == mailbox_base_name.lower(): log.warning(_("Downcasing mailbox name %r") % (mailbox_base_name)) mailbox_base_name = mailbox_base_name.lower() return self.has_folder('user%s%s' %(self.get_separator(), mailbox_base_name)) def user_mailbox_quota(self, mailbox_quota): pass def user_mailbox_rename(self, old_name, new_name, partition=None): self.connect() old_name = "user%s%s" % (self.get_separator(), old_name) new_name = "user%s%s" % (self.get_separator(), new_name) if old_name == new_name and partition is None: return if not self.has_folder(old_name): log.error(_("INBOX folder to rename (%s) does not exist") % (old_name)) if not self.has_folder(new_name) or not partition is None: log.info(_("Renaming INBOX from %s to %s") % (old_name, new_name)) try: self.imap.rename(old_name, new_name, partition) except: log.error(_("Could not rename INBOX folder %s to %s") % (old_name, new_name)) else: log.warning(_("Moving INBOX folder %s won't succeed as target folder %s already exists") % (old_name, new_name)) def user_mailbox_server(self, mailbox): server = self.imap.find_mailfolder_server(mailbox.lower()).lower() log.debug(_("Server for mailbox %r is %r") % (mailbox, server), level=8) return server def has_folder(self, folder): """ Check if the environment has a folder named folder. """ folders = self.imap.lm(self.folder_utf7(folder)) log.debug(_("Looking for folder '%s', we found folders: %r") % (folder, [self.folder_utf8(x) for x in folders]), level=8) # Greater then one, this folder may have subfolders. if len(folders) > 0: return True else: return False def _set_socket_keepalive(self, sock): sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) with open('/proc/sys/net/ipv4/tcp_keepalive_time', 'r') as f: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, (int)(f.read())) with open('/proc/sys/net/ipv4/tcp_keepalive_intvl', 'r') as f: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, (int)(f.read())) with open('/proc/sys/net/ipv4/tcp_keepalive_probes', 'r') as f: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, (int)(f.read())) def _set_kolab_mailfolder_acls(self, acls, folder=None, update=False): # special case, folder has no ACLs assigned and update was requested, # remove all existing ACL entries if update is True and isinstance(acls, list) and len(acls) == 0: acls = self.list_acls(folder) for subject in acls: log.debug( _("Removing ACL rights %s for subject %s on folder " + \ "%s") % (acls[subject], subject, folder), level=8) self.set_acl(folder, subject, '') return if isinstance(acls, basestring): acls = [ acls ] old_acls = None for acl in acls: exec("acl = %s" % (acl)) subject = acl[0] rights = acl[1] if len(acl) == 3: epoch = acl[2] else: epoch = (int)(time.time()) + 3600 # update mode, check existing entries if update is True: if old_acls is None: old_acls = self.list_acls(folder) for old_subject in old_acls: old_acls[old_subject] = old_acls[old_subject] if subject in old_acls: old_acls[subject] = None if epoch > (int)(time.time()): log.debug( _("Setting ACL rights %s for subject %s on folder " + \ "%s") % (rights, subject, folder), level=8) self.set_acl( folder, "%s" % (subject), "%s" % (rights) ) else: log.debug( _("Removing ACL rights %s for subject %s on folder " + \ "%s") % (rights, subject, folder), level=8) self.set_acl( folder, "%s" % (subject), "" ) # update mode, unset removed ACL entries if old_acls is not None: for subject in old_acls: if old_acls[subject] is not None: log.debug( _("Removing ACL rights %s for subject %s on folder " + \ "%s") % (old_acls[subject], subject, folder), level=8) self.set_acl(folder, subject, '') pass """ Blah functions """ def move_user_folders(self, users=[], domain=None): for user in users: if type(user) == dict: if 'old_mail' in user: inbox = "user/%s" % (user['mail']) old_inbox = "user/%s" % (user['old_mail']) if self.has_folder(old_inbox): log.debug(_("Found old INBOX folder %s") % (old_inbox), level=8) if not self.has_folder(inbox): log.info(_("Renaming INBOX from %s to %s") % (old_inbox, inbox)) self.imap.rename(old_inbox, inbox) self.inbox_folders.append(inbox) else: log.warning(_("Moving INBOX folder %s won't succeed as target folder %s already exists") % (old_inbox, inbox)) else: log.debug(_("Did not find old folder user/%s to rename") % (user['old_mail']), level=8) else: log.debug(_("Value for user is not a dictionary"), level=8) def set_quota(self, folder, quota): i = 0 while i < 10: try: self.imap._setquota(folder, quota) i = 10 except: self.disconnect() self.connect() i += 1 def set_user_folder_quota(self, users=[], primary_domain=None, secondary_domain=[], folders=[]): """ Sets the quota in IMAP using the authentication and authorization database 'quota' attribute for the users listed in parameter 'users' """ if conf.has_option(primary_domain, 'quota_attribute'): _quota_attr = conf.get(primary_domain, 'quota_attribute') else: auth_mechanism = conf.get('kolab', 'auth_mechanism') _quota_attr = conf.get(auth_mechanism, 'quota_attribute') _inbox_folder_attr = conf.get('cyrus-sasl', 'result_attribute') default_quota = auth.domain_default_quota(primary_domain) if default_quota == "" or default_quota is None: default_quota = 0 if len(users) == 0: users = auth.list_users(primary_domain) for user in users: quota = None if type(user) == dict: if _quota_attr in user: if type(user[_quota_attr]) == list: quota = user[_quota_attr].pop(0) elif type(user[_quota_attr]) == str: quota = user[_quota_attr] else: _quota = auth.get_user_attribute(primary_domain, user, _quota_attr) if _quota is None: quota = 0 else: quota = _quota if _inbox_folder_attr not in user: continue else: if type(user[_inbox_folder_attr]) == list: folder = "user/%s" % user[_inbox_folder_attr].pop(0) elif type(user[_inbox_folder_attr]) == str: folder = "user/%s" % user[_inbox_folder_attr] elif type(user) == str: quota = auth.get_user_attribute(user, _quota_attr) folder = "user/%s" % (user) folder = folder.lower() try: (used, current_quota) = self.imap.lq(folder) except: # TODO: Go in fact correct the quota. log.warning(_("Cannot get current IMAP quota for folder %s") % (folder)) used = 0 current_quota = 0 new_quota = conf.plugins.exec_hook("set_user_folder_quota", kw={ 'used': used, 'current_quota': current_quota, 'new_quota': (int)(quota), 'default_quota': (int)(default_quota), 'user': user } ) log.debug(_("Quota for %s currently is %s") % (folder, current_quota), level=7) if new_quota is None: continue if not int(new_quota) == int(quota): log.info(_("Adjusting authentication database quota for folder %s to %d") % (folder, int(new_quota))) quota = int(new_quota) auth.set_user_attribute(primary_domain, user, _quota_attr, new_quota) if not int(current_quota) == int(quota): log.info(_("Correcting quota for %s to %s (currently %s)") % (folder, quota, current_quota)) self.imap._setquota(folder, quota) def set_user_mailhost(self, users=[], primary_domain=None, secondary_domain=[], folders=[]): if conf.has_option(primary_domain, 'mailserver_attribute'): _mailserver_attr = conf.get(primary_domain, 'mailserver_attribute') else: auth_mechanism = conf.get('kolab', 'auth_mechanism') _mailserver_attr = conf.get(auth_mechanism, 'mailserver_attribute') _inbox_folder_attr = conf.get('cyrus-sasl', 'result_attribute') if len(users) == 0: users = auth.list_users(primary_domain) for user in users: mailhost = None if type(user) == dict: if _mailserver_attr in user: if type(user[_mailserver_attr]) == list: _mailserver = user[_mailserver_attr].pop(0) elif type(user[_mailserver_attr]) == str: _mailserver = user[_mailserver_attr] else: _mailserver = auth.get_user_attribute(primary_domain, user, _mailserver_attr) if _inbox_folder_attr not in user: continue else: if type(user[_inbox_folder_attr]) == list: folder = "user/%s" % user[_inbox_folder_attr].pop(0) elif type(user[_inbox_folder_attr]) == str: folder = "user/%s" % user[_inbox_folder_attr] elif type(user) == str: _mailserver = auth.get_user_attribute(user, _mailserver_attr) folder = "user/%s" % (user) folder = folder.lower() _current_mailserver = self.imap.find_mailfolder_server(folder) if _mailserver is not None: # TODO: if not _current_mailserver == _mailserver: self.imap._xfer(folder, _current_mailserver, _mailserver) else: auth.set_user_attribute(primary_domain, user, _mailserver_attr, _current_mailserver) def parse_mailfolder(self, mailfolder): return self.imap.parse_mailfolder(mailfolder) def expunge_user_folders(self, inbox_folders=None): """ Delete folders that have no equivalent user qualifier in the list of users passed to this function, ... TODO: Explain the domain parameter, and actually get it to work properly. This also relates to threading for multi-domain deployments. Parameters: users A list of users. Can be a list of user qualifiers, e.g. [ 'user1', 'user2' ] or a list of user attribute dictionaries, e.g. [ { 'user1': { 'attr': 'value' } } ] primary_domain, secondary_domains """ if inbox_folders is None: inbox_folders = [] folders = self.list_user_folders() for folder in folders: log.debug(_("Checking folder: %s") % (folder), level=1) try: if inbox_folders.index(folder) > -1: continue else: log.info(_("Folder has no corresponding user (1): %s") % (folder)) self.delete_mailfolder("user/%s" % (folder)) except: log.info(_("Folder has no corresponding user (2): %s") % (folder)) try: self.delete_mailfolder("user/%s" % (folder)) except: pass def delete_mailfolder(self, mailfolder_path): """ Deletes a mail folder described by mailfolder_path. """ mbox_parts = self.parse_mailfolder(mailfolder_path) if mbox_parts is None: # We got user identifier only log.error(_("Please don't give us just a user identifier")) return log.info(_("Deleting folder %s") % (mailfolder_path)) self.imap.dm(self.folder_utf7(mailfolder_path)) def get_quota(self, mailfolder_path): try: return self.lq(self.folder_utf7(mailfolder_path)) except: return def get_quota_root(self, mailfolder_path): return self.lqr(self.folder_utf7(mailfolder_path)) def list_acls(self, folder): """ List the ACL entries on a folder """ return self.imap.lam(self.folder_utf7(folder)) def list_folders(self, pattern): return [self.folder_utf8(x) for x in self.lm(self.folder_utf7(pattern))] def list_user_folders(self, primary_domain=None, secondary_domains=[]): """ List the INBOX folders in the IMAP backend. Returns a list of unique base folder names. """ _folders = self.imap.lm("user/%") # TODO: Replace the .* below with a regex representing acceptable DNS # domain names. domain_re = ".*\.?%s$" acceptable_domain_name_res = [] if primary_domain is not None: for domain in [ primary_domain ] + secondary_domains: acceptable_domain_name_res.append(domain_re % (domain)) folders = [] for folder in _folders: folder_name = None if len(folder.split('@')) > 1: # TODO: acceptable domain name spaces #acceptable = False #for domain_name_re in acceptable_domain_name_res: #prog = re.compile(domain_name_re) #if prog.match(folder.split('@')[1]): #print "Acceptable indeed" #acceptable = True #if not acceptable: #print "%s is not acceptable against %s yet using %s" % (folder.split('@')[1], folder, domain_name_re) #if acceptable: #folder_name = "%s@%s" % (folder.split(self.separator)[1].split('@')[0], folder.split('@')[1]) folder_name = "%s@%s" % (folder.split(self.get_separator())[1].split('@')[0], folder.split('@')[1]) else: folder_name = "%s" % (folder.split(self.get_separator())[1]) if folder_name is not None: if not folder_name in folders: folders.append(folder_name) return folders def lm(self, *args, **kw): return self.imap.lm(*args, **kw) def lq(self, *args, **kw): return self.imap.lq(*args, **kw) def lqr(self, *args, **kw): try: return self.imap.lqr(*args, **kw) except: return (None, None, None) def undelete_mailfolder(self, *args, **kw): self.imap.undelete_mailfolder(*args, **kw) diff --git a/pykolab/imap/cyrus.py b/pykolab/imap/cyrus.py index 765b0ee..91df698 100644 --- a/pykolab/imap/cyrus.py +++ b/pykolab/imap/cyrus.py @@ -1,631 +1,634 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from __future__ import print_function import cyruslib import sys import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import pykolab from pykolab import constants from pykolab.imap import IMAP from pykolab.translate import _ log = pykolab.getLogger('pykolab.imap') conf = pykolab.getConf() class Cyrus(cyruslib.CYRUS): """ Abstraction class for some common actions to do exclusively in Cyrus. For example, the following functions require the commands to be executed against the backend server if a murder is being used. - Setting quota - Renaming the top-level mailbox - Setting annotations """ setquota = cyruslib.CYRUS.sq def __init__(self, uri): """ Initialize this class, but do not connect yet. """ port = None result = urlparse(uri) if hasattr(result, 'hostname'): scheme = result.scheme hostname = result.hostname port = result.port else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if not port: if scheme == 'imap': port = 143 else: port = 993 self.server = hostname self.uri = "%s://%s:%s" % (scheme, hostname, port) while 1: try: cyruslib.CYRUS.__init__(self, self.uri) break except cyruslib.CYRUSError: log.warning( _("Could not connect to Cyrus IMAP server %r") % ( self.uri ) ) time.sleep(10) if conf.debuglevel > 8: self.VERBOSE = True self.m.debug = 5 sl = pykolab.logger.StderrToLogger(log) # imaplib debug outputs everything to stderr. Redirect to Logger sys.stderr = sl # cyruslib debug outputs everything to LOGFD. Redirect to Logger self.LOGFD = sl # Initialize our variables self.separator = self.SEP # Placeholder for known mailboxes on known servers self.mbox = {} def __del__(self): pass def connect(self, uri): """ Dummy connect function that checks if the server that we want to connect to is actually the server we are connected to. Uses pykolab.imap.IMAP.connect() in the background. """ port = None result = urlparse(uri) if hasattr(result, 'hostname'): scheme = result.scheme hostname = result.hostname port = result.port else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if not port: if scheme == 'imap': port = 143 else: port = 993 if hostname == self.server: return imap = IMAP() imap.connect(uri=uri) if not self.SEP == self.separator: self.separator = self.SEP def login(self, *args, **kw): """ Login to the Cyrus IMAP server through cyruslib.CYRUS, but set our hierarchy separator. """ try: cyruslib.CYRUS.login(self, *args, **kw) except cyruslib.CYRUSError as errmsg: log.error("Login to Cyrus IMAP server failed: %r", errmsg) except Exception as errmsg: log.exception(errmsg) self.separator = self.SEP try: self._id() except Exception: pass log.debug( _("Continuing with separator: %r") % (self.separator), level=8 ) self.murder = False for capability in self.m.capabilities: if capability.startswith("MUPDATE="): log.debug( _("Detected we are running in a Murder topology"), level=8 ) self.murder = True if not self.murder: log.debug( _("This system is not part of a murder topology"), level=8 ) def find_mailfolder_server(self, mailfolder): annotations = {} _mailfolder = self.parse_mailfolder(mailfolder) prefix = _mailfolder['path_parts'][0] mbox = _mailfolder['path_parts'][1] if _mailfolder['domain'] is not None: mailfolder = "%s%s%s@%s" % ( prefix, self.separator, mbox, _mailfolder['domain'] ) # TODO: Workaround for undelete if len(self.lm(mailfolder)) < 1 and 'hex_timestamp' in _mailfolder: mailfolder = self.folder_utf7("DELETED/%s%s%s@%s" % ( self.separator.join(_mailfolder['path_parts']), self.separator, _mailfolder['hex_timestamp'], _mailfolder['domain']) ) # TODO: Murder capabilities may have been suppressed using Cyrus IMAP # configuration. if not self.murder: return self.server log.debug( _("Checking actual backend server for folder %s " + "through annotations") % ( mailfolder ), level=8 ) if mailfolder in self.mbox: log.debug( _( "Possibly reproducing the find " + "mailfolder server answer from " + "previously detected and stored " + "annotation value: %r" ) % ( self.mbox[mailfolder] ), level=8 ) if not self.mbox[mailfolder] == self.server: return self.mbox[mailfolder] max_tries = 20 num_try = 0 ann_path = "/vendor/cmu/cyrus-imapd/server" s_ann_path = "/shared%s" % (ann_path) while 1: num_try += 1 annotations = self._getannotation( '"%s"' % (mailfolder), ann_path ) if mailfolder in annotations: if s_ann_path in annotations[mailfolder]: break if max_tries <= num_try: log.error( _("Could not get the annotations after %s tries.") % ( num_try ) ) annotations = { mailfolder: { s_ann_path: self.server } } break log.warning( _("No annotations for %s: %r") % ( mailfolder, annotations ) ) time.sleep(1) server = annotations[mailfolder][s_ann_path] self.mbox[mailfolder] = server log.debug( _("Server for INBOX folder %s is %s") % ( mailfolder, server ), level=8 ) return server def folder_utf7(self, folder): from pykolab import imap_utf7 return imap_utf7.encode(folder) def folder_utf8(self, folder): from pykolab import imap_utf7 return imap_utf7.decode(folder) def _id(self, identity=None): if identity is None: identity = '("name" "Python/Kolab" "version" "%s")' % (constants.__version__) typ, dat = self.m._simple_command('ID', identity) res, dat = self.m._untagged_response(typ, dat, 'ID') def _setquota(self, mailfolder, quota): """ Login to the actual backend server. """ server = self.find_mailfolder_server(mailfolder) self.connect(self.uri.replace(self.server, server)) log.debug( _("Setting quota for folder %s to %s") % ( mailfolder, quota ), level=8 ) try: self.m.setquota(mailfolder, quota) except: log.error( _("Could not set quota for mailfolder %s") % ( mailfolder ) ) def _rename(self, from_mailfolder, to_mailfolder, partition=None): """ Login to the actual backend server, then rename. """ server = self.find_mailfolder_server(from_mailfolder) self.connect(self.uri.replace(self.server, server)) if partition is not None: log.debug( _("Moving INBOX folder %s to %s on partition %s") % ( from_mailfolder, to_mailfolder, partition ), level=8 ) else: log.debug( _("Moving INBOX folder %s to %s") % ( from_mailfolder, to_mailfolder ), level=8 ) self.m.rename( self.folder_utf7(from_mailfolder), self.folder_utf7(to_mailfolder), partition ) def _getannotation(self, *args, **kw): return self.getannotation(*args, **kw) def _setannotation(self, mailfolder, annotation, value, shared=False): """ Login to the actual backend server, then set annotation. """ try: server = self.find_mailfolder_server(mailfolder) except: server = self.server log.debug( _("Setting annotation %s on folder %s") % ( annotation, mailfolder ), level=8 ) try: self.setannotation(mailfolder, annotation, value, shared) except cyruslib.CYRUSError as errmsg: log.error( _("Could not set annotation %r on mail folder %r: %r") % ( annotation, mailfolder, errmsg ) ) def _xfer(self, mailfolder, current_server, new_server): self.connect(self.uri.replace(self.server, current_server)) log.debug( _("Transferring folder %s from %s to %s") % ( mailfolder, current_server, new_server ), level=8 ) self.xfer(mailfolder, new_server) def undelete_mailfolder( self, mailfolder, to_mailfolder=None, recursive=True ): """ Login to the actual backend server, then "undelete" the mailfolder. 'mailfolder' may be a string representing either of the following two options; - the fully qualified pathof the deleted folder in its current location, such as, for a deleted INBOX folder originally known as "user/userid[@domain]"; "DELETED/user/userid/hex[@domain]" - the original folder name, such as; "user/userid[@domain]" 'to_mailfolder' may be the target folder to "undelete" the deleted folder to. If not specified, the original folder name is used. """ # Placeholder for folders we have recovered already. target_folders = [] mailfolder = self.parse_mailfolder(mailfolder) undelete_folders = self._find_deleted_folder(mailfolder) if to_mailfolder is not None: target_mbox = self.parse_mailfolder(to_mailfolder) else: target_mbox = mailfolder for undelete_folder in undelete_folders: undelete_mbox = self.parse_mailfolder(undelete_folder) prefix = undelete_mbox['path_parts'].pop(0) mbox = undelete_mbox['path_parts'].pop(0) if to_mailfolder is None: target_folder = self.separator.join([prefix, mbox]) else: target_folder = self.separator.join(target_mbox['path_parts']) if to_mailfolder is not None: target_folder = "%s%s%s" % ( target_folder, self.separator, mbox ) if not len(undelete_mbox['path_parts']) == 0: target_folder = "%s%s%s" % ( target_folder, self.separator, self.separator.join(undelete_mbox['path_parts']) ) if target_folder in target_folders: target_folder = "%s%s%s" % ( target_folder, self.separator, undelete_mbox['hex_timestamp'] ) target_folders.append(target_folder) if target_mbox['domain'] is not None: target_folder = "%s@%s" % ( target_folder, target_mbox['domain'] ) log.info( _("Undeleting %s to %s") % ( undelete_folder, target_folder ) ) target_server = self.find_mailfolder_server(target_folder) source_server = self.find_mailfolder_server(undelete_folder) if hasattr(conf, 'dry_run') and not conf.dry_run: undelete_folder = self.folder_utf7(undelete_folder) target_folder = self.folder_utf7(target_folder) if not target_server == source_server: self.xfer(undelete_folder, target_server) self.rename(undelete_folder, target_folder) else: if not target_server == source_server: print(_("Would have transferred %s from %s to %s") % ( undelete_folder, source_server, target_server ), file=sys.stdout) print(_("Would have renamed %s to %s") % ( undelete_folder, target_folder ), file=sys.stdout) def parse_mailfolder(self, mailfolder): """ Parse a mailfolder name to it's parts. Takes a fully qualified mailfolder or mailfolder sub-folder. """ mbox = { 'domain': None } if len(mailfolder.split('/')) > 1: self.separator = '/' # Split off the virtual domain identifier, if any if len(mailfolder.split('@')) > 1: mbox['domain'] = mailfolder.split('@')[1] mbox['path_parts'] = mailfolder.split('@')[0].split(self.separator) else: mbox['path_parts'] = mailfolder.split(self.separator) # See if the path that has been specified is the current location for # the deleted folder, or the original location, we have to find the # deleted folder for. if not mbox['path_parts'][0] in ['user', 'shared']: deleted_prefix = mbox['path_parts'].pop(0) # See if the hexadecimal timestamp is actually hexadecimal. # This prevents "DELETED/user/userid/Sent", but not # "DELETED/user/userid/FFFFFF" from being specified. try: hexstamp = mbox['path_parts'][(len(mbox['path_parts'])-1)] epoch = int(hexstamp, 16) try: timestamp = time.asctime(time.gmtime(epoch)) except: return None except: return None # Verify that the input for the deleted folder is actually a # deleted folder. verify_folder_search = "%(dp)s%(sep)s%(mailfolder)s" % { 'dp': deleted_prefix, 'sep': self.separator, 'mailfolder': self.separator.join(mbox['path_parts']) } if mbox['domain'] is not None: verify_folder_search = "%s@%s" % ( verify_folder_search, mbox['domain'] ) if ' ' in verify_folder_search: folders = self.lm( '"%s"' % self.folder_utf7(verify_folder_search) ) else: folders = self.lm(self.folder_utf7(verify_folder_search)) # NOTE: Case also covered is valid hexadecimal folders; won't be # the actual check as intended, but doesn't give you anyone else's # data unless... See the following: # # TODO: Case not covered is usernames that are hexadecimal. # # We could probably attempt to convert the int(hex) into a # time.gmtime(), but it still would not cover all cases. # # If no folders were found... well... then there you go. if len(folders) < 1: return None # Pop off the hex timestamp, which turned out to be valid mbox['hex_timestamp'] = mbox['path_parts'].pop() return mbox def _find_deleted_folder(self, mbox): """ Give me the parts that are in an original mailfolder name and I'll find the deleted folder name. TODO: It finds virtdomain folders for non-virtdomain searches. """ deleted_folder_search = "%(deleted_prefix)s%(separator)s%(mailfolder)s%(separator)s*" % { # TODO: The prefix used is configurable 'deleted_prefix': "DELETED", 'mailfolder': self.separator.join(mbox['path_parts']), 'separator': self.separator, } if mbox['domain'] is not None: deleted_folder_search = "%s@%s" % ( deleted_folder_search, mbox['domain'] ) folders = self.lm(self.folder_utf7(deleted_folder_search)) # The folders we have found at this stage include virtdomain folders. # # For example, having searched for user/userid, it will also find # user/userid@example.org # # Here, we explicitly remove any virtdomain folders. if mbox['domain'] is None: _folders = [] for folder in folders: if len(folder.split('@')) < 2: _folders.append(folder) folders = _folders return [self.folder_utf8(x) for x in folders] diff --git a/pykolab/imap/dovecot.py b/pykolab/imap/dovecot.py index eb854ab..869ab5b 100644 --- a/pykolab/imap/dovecot.py +++ b/pykolab/imap/dovecot.py @@ -1,636 +1,639 @@ # -*- coding: utf-8 -*- # Copyright 2015 Instituto Tecnológico de Informática (http://www.iti.es) # # Sergio Talens-Oliag (ITI) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # ----- # Note: # # This file is based on the original cyrus.py driver from Kolab, # replacing annotation related functions with metadata functions; to use it # on a debian installation it can be copied to the path: # # /usr/share/pyshared/pykolab/imap/dovecot.py # # The file needs some review, as some functions have been modified to behave # as we want, but the real changes should be done on other places. # # As an example, with annotations you can get all existing annotations with # one call, but if we use metadatata we have to ask for specific variables, # there is no function to get all of them at once (at least on the RFC); in # our case when a pattern like '*' is received we look for fields of the form # 'vendor/kolab/folder-type', as we know they are the fields the functions we # are using need. # ----- from __future__ import print_function import cyruslib import imaplib import sys import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import pykolab from pykolab.imap import IMAP from pykolab.translate import _ log = pykolab.getLogger('pykolab.imap') conf = pykolab.getConf() # BEG: Add GETMETADATA and SETMETADATA support to the cyruslib IMAP objects Commands = { 'GETMETADATA': ('AUTH',), 'SETMETADATA': ('AUTH',), } imaplib.Commands.update(Commands) def imap_getmetadata(self, mailbox, pattern='*', shared=None): # If pattern is '*' clean pattern and search all entries under /shared # and/or /private (depens on the shared parameter value) to emulate the # ANNOTATEMORE behaviour if pattern == '*': pattern = '' options = '(DEPTH infinity)' else: options = '(DEPTH 0)' if shared == None: entries = '( /shared%s /private%s )' % (pattern, pattern) elif shared: entries = "/shared%s" % pattern else: entries = " /private%s" % pattern typ, dat = self._simple_command('GETMETADATA', options, mailbox, entries) return self._untagged_response(typ, dat, 'METADATA') def imap_setmetadata(self, mailbox, desc, value, shared=False): if value: value = value.join(['"', '"']) else: value = "NIL" if shared: typ, dat = self._simple_command('SETMETADATA', mailbox, "(/shared%s %s)" % (desc,value)) else: typ, dat = self._simple_command('SETMETADATA', mailbox, "(/private%s %s)" % (desc,value)) return self._untagged_response(typ, dat, 'METADATA') # Bind the new methods to the cyruslib IMAP4 and IMAP4_SSL objects from types import MethodType cyruslib.IMAP4.getmetadata = MethodType(imap_getmetadata, None, cyruslib.IMAP4) cyruslib.IMAP4.setmetadata = MethodType(imap_setmetadata, None, cyruslib.IMAP4) cyruslib.IMAP4_SSL.getmetadata = MethodType(imap_getmetadata, None, cyruslib.IMAP4_SSL) cyruslib.IMAP4_SSL.setmetadata = MethodType(imap_setmetadata, None, cyruslib.IMAP4_SSL) # END: Add GETMETADATA and SETMETADATA support to the cyruslib IMAP objects # Auxiliary functions def _get_line_entries(lines): """Function to get metadata entries """ entries = {} name = None value = "" vlen = 0 for line in lines: line_len = len(line) i = 0 while i < line_len: if name == None: if line[i] == '/': j = i while j < line_len: if line[j] == ' ': break j += 1 name = line[i:j] i = j elif vlen != 0: j = i + vlen if j > line_len: value += line[i:line_len] vlen -= line_len - i else: value += line[i:i+vlen] if value in ('', 'NIL'): entries[name] = "" else: entries[name] = value name = None value = "" vlen = 0 elif line[i] == '{': j = i while j < line_len: if line[j] == '}': vlen = int(line[i+1:j]) break j += 1 i = j elif line[i] != ' ': j = i if line[i] == '"': while j < line_len: # Skip quoted text if line[j] == '\\': j += 2 continue elif line[j] == '"': break j += 1 else: while j < line_len: if line[j] == ' ' or line[j] == ')': break j += 1 value = line[i:j] if value in ('', 'NIL'): entries[name] = "" else: entries[name] = value name = None value = "" i = j i += 1 return entries class Dovecot(cyruslib.CYRUS): """ Abstraction class for some common actions to do exclusively in Dovecot. Initially based on the Cyrus driver, will remove dependencies on cyruslib later; right now this module has only been tested to use the dovecot metadata support (no quota or folder operations tests have been performed). """ setquota = cyruslib.CYRUS.sq def __init__(self, uri): """ Initialize this class, but do not connect yet. """ port = None result = urlparse(uri) if hasattr(result, 'hostname'): scheme = result.scheme hostname = result.hostname port = result.port else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if not port: if scheme == 'imap': port = 143 else: port = 993 self.server = hostname self.uri = "%s://%s:%s" % (scheme,hostname,port) while 1: try: cyruslib.CYRUS.__init__(self, self.uri) break except cyruslib.CYRUSError: log.warning(_("Could not connect to Dovecot IMAP server %r") % (self.uri)) time.sleep(10) if conf.debuglevel > 8: self.VERBOSE = True self.m.debug = 5 # Initialize our variables self.separator = self.SEP # Placeholder for known mailboxes on known servers self.mbox = {} # By default don't assume that we have metadata support self.metadata = False def __del__(self): pass def __verbose(self, msg): if self.VERBOSE: print(msg, file=self.LOGFD) def connect(self, uri): """ Dummy connect function that checks if the server that we want to connect to is actually the server we are connected to. Uses pykolab.imap.IMAP.connect() in the background. """ port = None result = urlparse(uri) if hasattr(result, 'hostname'): scheme = result.scheme hostname = result.hostname port = result.port else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if not port: if scheme == 'imap': port = 143 else: port = 993 if hostname == self.server: return imap = IMAP() imap.connect(uri=uri) if not self.SEP == self.separator: self.separator = self.SEP def login(self, *args, **kw): """ Login to the Dovecot IMAP server through cyruslib.CYRUS, but set our hierarchy separator. """ cyruslib.CYRUS.login(self, *args, **kw) self.separator = self.SEP log.debug(_("Continuing with separator: %r") % (self.separator), level=8) # Check if we have metadata support or not self.metadata = False typ, dat = self.m.capability() for capability in tuple(dat[-1].upper().split()): if capability.startswith("METADATA"): log.debug(_("Detected METADATA support"), level=8) self.metadata = True if not self.metadata: log.debug(_("This system does not support METADATA: '%s'" % ','.join(self.m.capabilities)), level=8) def find_mailfolder_server(self, mailfolder): # Nothing to do in dovecot, returns the current server return self.server def folder_utf7(self, folder): from pykolab import imap_utf7 return imap_utf7.encode(folder) def folder_utf8(self, folder): from pykolab import imap_utf7 return imap_utf7.decode(folder) def _setquota(self, mailfolder, quota): # Removed server reconnection for dovecot, we only have one server log.debug(_("Setting quota for folder %s to %s") % (mailfolder,quota), level=8) try: self.m.setquota(mailfolder, quota) except: log.error(_("Could not set quota for mailfolder %s") % (mailfolder)) def _rename(self, from_mailfolder, to_mailfolder, partition=None): # Removed server reconnection for dovecot, we only have one server if not partition == None: log.debug(_("Moving INBOX folder %s to %s on partition %s") % (from_mailfolder,to_mailfolder, partition), level=8) else: log.debug(_("Moving INBOX folder %s to %s") % (from_mailfolder,to_mailfolder), level=8) self.m.rename(self.folder_utf7(from_mailfolder), self.folder_utf7(to_mailfolder), '"%s"' % (partition)) # BEG: METADATA support functions ... quite similar to annotations, really def _getmetadata(self, mailbox, pattern='*', shared=None): """Get Metadata""" # This test needs to be reviewed #if not self.metadata: # return {} # Annotations vs. Metadata fix ... we set a pattern that we know is # good enough for our purposes for now, but the fact is that the # calling programs should be fixed instead. res, data = self.m.getmetadata(self.decode(mailbox), pattern, shared) if (len(data) == 1) and data[0] is None: self.__verbose( '[GETMETADATA %s] No results' % (mailbox) ) return {} # Get the first response line (it can be a string or a tuple) if isinstance(data[0], tuple): fline = data[0][0] else: fline = data[0] # Find the folder name fbeg = 0 fend = -1 if fline[0] == '"': # Quoted name fbeg = 1 i = 1 while i < len(fline): if fline[i] == '"': # folder name ended unless the previous char is \ (we # should test more, this test would fail if we had a \ # at the end of the folder name, but we leave it at that # right now if fline[i-1] != '\\': fend = i break i += 1 else: # For unquoted names the first word is the folder name fend = fline.find(' ') # No mailbox found if fend < 0: self.__verbose( '[GETMETADATA %s] Mailbox not found in results' % (mailbox) ) return {} # Folder name folder = fline[fbeg:fend] # Check mailbox name against the folder name if folder != mailbox: quoted_mailbox = "\"%s\"" % (mailbox) if folder != quoted_mailbox: self.__verbose( '[GETMETADATA %s] Mailbox \'%s\' is not the same as \'%s\'' \ % (mailbox, quoted_mailbox, folder) ) return {} # Process the rest of the first line, the first value will be # available after the first '(' found i=fend ebeg = -1 while i < len(fline): if fline[i] == '(': ebeg = i+1 break i += 1 if ebeg < 0: self.__verbose( '[GETMETADATA %s] Mailbox has no values, skipping' % (mailbox) ) return {} # This variable will start with an entry name and will continue with # the value lenght or the value nfline = fline[ebeg:] if isinstance(data[0], tuple): entries = _get_line_entries((nfline,) + data[0][1:]) else: entries = _get_line_entries((nfline,)) for line in data[1:]: if isinstance(line, tuple): lentries = _get_line_entries(line) else: lentries = _get_line_entries([line,]) if lentries != None and lentries != {}: entries.update(lentries) mdat = { mailbox: entries }; return mdat def _setmetadata(self, mailbox, desc, value, shared=False): """Set METADADATA""" res, msg = self.m.setmetadata(self.decode(mailbox), desc, value, shared) self.__verbose( '[SETMETADATA %s] %s: %s' % (mailbox, res, msg[0]) ) # Use metadata instead of annotations def _getannotation(self, *args, **kw): return self._getmetadata(*args, **kw) def getannotation(self, *args, **kw): return self._getmetadata(*args, **kw) # Use metadata instead of annotations def _setannotation(self, *args, **kw): return self._setmetadata(*args, **kw) def setannotation(self, *args, **kw): return self._setmetadata(*args, **kw) # END: METADATA / Annotations # The functions that follow are the same ones used with Cyrus, probably a # review is needed def _xfer(self, mailfolder, current_server, new_server): self.connect(self.uri.replace(self.server,current_server)) log.debug(_("Transferring folder %s from %s to %s") % (mailfolder, current_server, new_server), level=8) self.xfer(mailfolder, new_server) def undelete_mailfolder(self, mailfolder, to_mailfolder=None, recursive=True): """ Login to the actual backend server, then "undelete" the mailfolder. 'mailfolder' may be a string representing either of the following two options; - the fully qualified pathof the deleted folder in its current location, such as, for a deleted INBOX folder originally known as "user/userid[@domain]"; "DELETED/user/userid/hex[@domain]" - the original folder name, such as; "user/userid[@domain]" 'to_mailfolder' may be the target folder to "undelete" the deleted folder to. If not specified, the original folder name is used. """ # Placeholder for folders we have recovered already. target_folders = [] mailfolder = self.parse_mailfolder(mailfolder) undelete_folders = self._find_deleted_folder(mailfolder) if not to_mailfolder == None: target_mbox = self.parse_mailfolder(to_mailfolder) else: target_mbox = mailfolder for undelete_folder in undelete_folders: undelete_mbox = self.parse_mailfolder(undelete_folder) prefix = undelete_mbox['path_parts'].pop(0) mbox = undelete_mbox['path_parts'].pop(0) if to_mailfolder == None: target_folder = self.separator.join([prefix,mbox]) else: target_folder = self.separator.join(target_mbox['path_parts']) if not to_mailfolder == None: target_folder = "%s%s%s" % (target_folder,self.separator,mbox) if not len(undelete_mbox['path_parts']) == 0: target_folder = "%s%s%s" % (target_folder,self.separator,self.separator.join(undelete_mbox['path_parts'])) if target_folder in target_folders: target_folder = "%s%s%s" % (target_folder,self.separator,undelete_mbox['hex_timestamp']) target_folders.append(target_folder) if not target_mbox['domain'] == None: target_folder = "%s@%s" % (target_folder,target_mbox['domain']) log.info(_("Undeleting %s to %s") % (undelete_folder,target_folder)) target_server = self.find_mailfolder_server(target_folder) if hasattr(conf,'dry_run') and not conf.dry_run: if not target_server == self.server: self.xfer(undelete_folder,target_server) self.rename(undelete_folder,target_folder) else: if not target_server == self.server: print(_("Would have transfered %s from %s to %s") % (undelete_folder, self.server, target_server), file=sys.stdout) print(_("Would have renamed %s to %s") % (undelete_folder, target_folder), file=sys.stdout) def parse_mailfolder(self, mailfolder): """ Parse a mailfolder name to it's parts. Takes a fully qualified mailfolder or mailfolder sub-folder. """ mbox = { 'domain': None } if len(mailfolder.split('/')) > 1: self.separator = '/' # Split off the virtual domain identifier, if any if len(mailfolder.split('@')) > 1: mbox['domain'] = mailfolder.split('@')[1] mbox['path_parts'] = mailfolder.split('@')[0].split(self.separator) else: mbox['path_parts'] = mailfolder.split(self.separator) # See if the path that has been specified is the current location for # the deleted folder, or the original location, we have to find the deleted # folder for. if not mbox['path_parts'][0] in [ 'user', 'shared' ]: deleted_prefix = mbox['path_parts'].pop(0) # See if the hexadecimal timestamp is actually hexadecimal. # This prevents "DELETED/user/userid/Sent", but not # "DELETED/user/userid/FFFFFF" from being specified. try: epoch = int(mbox['path_parts'][(len(mbox['path_parts'])-1)], 16) try: timestamp = time.asctime(time.gmtime(epoch)) except: return None except: return None # Verify that the input for the deleted folder is actually a # deleted folder. verify_folder_search = "%(dp)s%(sep)s%(mailfolder)s" % { 'dp': deleted_prefix, 'sep': self.separator, 'mailfolder': self.separator.join(mbox['path_parts']) } if not mbox['domain'] == None: verify_folder_search = "%s@%s" % (verify_folder_search, mbox['domain']) if ' ' in verify_folder_search: folders = self.lm('"%s"' % self.folder_utf7(verify_folder_search)) else: folders = self.lm(self.folder_utf7(verify_folder_search)) # NOTE: Case also covered is valid hexadecimal folders; won't be the # actual check as intended, but doesn't give you anyone else's data # unless... See the following: # # TODO: Case not covered is usernames that are hexadecimal. # # We could probably attempt to convert the int(hex) into a time.gmtime(), # but it still would not cover all cases. # # If no folders were found... well... then there you go. if len(folders) < 1: return None # Pop off the hex timestamp, which turned out to be valid mbox['hex_timestamp'] = mbox['path_parts'].pop() return mbox def _find_deleted_folder(self, mbox): """ Give me the parts that are in an original mailfolder name and I'll find the deleted folder name. TODO: It finds virtdomain folders for non-virtdomain searches. """ deleted_folder_search = "%(deleted_prefix)s%(separator)s%(mailfolder)s%(separator)s*" % { # TODO: The prefix used is configurable 'deleted_prefix': "DELETED", 'mailfolder': self.separator.join(mbox['path_parts']), 'separator': self.separator, } if not mbox['domain'] == None: deleted_folder_search = "%s@%s" % (deleted_folder_search,mbox['domain']) folders = self.lm(deleted_folder_search) # The folders we have found at this stage include virtdomain folders. # # For example, having searched for user/userid, it will also find # user/userid@example.org # # Here, we explicitely remove any virtdomain folders. if mbox['domain'] == None: _folders = [] for folder in folders: if len(folder.split('@')) < 2: _folders.append(folder) folders = _folders return folders diff --git a/pykolab/plugins/sievemgmt/__init__.py b/pykolab/plugins/sievemgmt/__init__.py index f7e5368..02f5daa 100644 --- a/pykolab/plugins/sievemgmt/__init__.py +++ b/pykolab/plugins/sievemgmt/__init__.py @@ -1,431 +1,434 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import pykolab from pykolab import utils from pykolab.auth import Auth from pykolab.translate import _ conf = pykolab.getConf() log = pykolab.getLogger('pykolab.plugin_sievemgmt') import sys import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse class KolabSievemgmt(object): """ Plugin to manage Sieve scripts according to KEP #14. """ def __init__(self): pass def add_options(self, *args, **kw): pass def sieve_mgmt_refresh(self, *args, **kw): """ The arguments passed to the 'sieve_mgmt_refresh' hook: user - the user identifier """ if not len(kw) == 1 or 'user' not in kw: log.error(_("Wrong number of arguments for sieve management plugin")) return else: address = kw['user'] auth = Auth() auth.connect() user = auth.find_recipient(address) log.info("User for address %r: %r" % (address, user)) # Get the main, default backend backend = conf.get('kolab', 'imap_backend') if len(address.split('@')) > 1: domain = address.split('@')[1] else: domain = conf.get('kolab', 'primary_domain') if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: uri = conf.get(backend, 'uri') hostname = None port = None result = urlparse(uri) if hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') port = 4190 # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') import sievelib.managesieve sieveclient = sievelib.managesieve.Client(hostname, port, conf.debuglevel > 8) sieveclient.connect(None, None, True) sieveclient._plain_authentication(admin_login, admin_password, address) sieveclient.authenticated = True result = sieveclient.listscripts() if result == None: active = None scripts = [] else: active, scripts = result log.debug(_("Found the following scripts for user %s: %s") % (address, ','.join(scripts)), level=8) log.debug(_("And the following script is active for user %s: %s") % (address, active), level=8) mgmt_required_extensions = [] mgmt_script = """# # MANAGEMENT # """ user = auth.get_entry_attributes(domain, user, ['*']) # # Vacation settings (a.k.a. Out of Office) # vacation_active = None vacation_text = None vacation_uce = None vacation_noreact_domains = None vacation_react_domains = None vacation_active_attr = conf.get('sieve', 'vacation_active_attr') vacation_text_attr = conf.get('sieve', 'vacation_text_attr') vacation_uce_attr = conf.get('sieve', 'vacation_uce_attr') vacation_noreact_domains_attr = conf.get('sieve', 'vacation_noreact_domains_attr') vacation_react_domains_attr = conf.get('sieve', 'vacation_react_domains_attr') if not vacation_text_attr == None: if vacation_active_attr in user: vacation_active = utils.true_or_false(user[vacation_active_attr]) else: vacation_active = False if vacation_text_attr in user: vacation_text = user[vacation_text_attr] else: vacation_active = False if vacation_uce_attr in user: vacation_uce = utils.true_or_false(user[vacation_uce_attr]) else: vacation_uce = False if vacation_react_domains_attr in user: if isinstance(user[vacation_react_domains_attr], list): vacation_react_domains = user[vacation_react_domains_attr] else: vacation_react_domains = [ user[vacation_react_domains_attr] ] else: if vacation_noreact_domains_attr in user: if isinstance(user[vacation_noreact_domains_attr], list): vacation_noreact_domains = user[vacation_noreact_domains_attr] else: vacation_noreact_domains = [ user[vacation_noreact_domains_attr] ] else: vacation_noreact_domains = [] # # Delivery to Folder # dtf_active_attr = conf.get('sieve', 'deliver_to_folder_active') if not dtf_active_attr == None: if dtf_active_attr in user: dtf_active = utils.true_or_false(user[dtf_active_attr]) else: dtf_active = False else: # TODO: Not necessarily de-activated, the *Active attributes are # not supposed to charge this - check the deliver_to_folder_attr # attribute value for a value. dtf_active = False if dtf_active: dtf_folder_name_attr = conf.get('sieve', 'deliver_to_folder_attr') if not dtf_folder_name_attr == None: if dtf_folder_name_attr in user: dtf_folder = user[dtf_folder_name_attr] else: log.warning(_("Delivery to folder active, but no folder name attribute available for user %r") % (user)) dtf_active = False else: log.error(_("Delivery to folder active, but no folder name attribute configured")) dtf_active = False # # Folder name to delivery spam to. # # Global or local. # sdf_filter = True sdf = conf.get('sieve', 'spam_global_folder') if sdf == None: sdf = conf.get('sieve', 'spam_personal_folder') if sdf == None: sdf_filter = False # # Mail forwarding # forward_active = None forward_addresses = [] forward_keepcopy = None forward_uce = None forward_active_attr = conf.get('sieve', 'forward_address_active') if not forward_active_attr == None: if forward_active_attr in user: forward_active = utils.true_or_false(user[forward_active_attr]) else: forward_active = False if not forward_active == False: forward_address_attr = conf.get('sieve', 'forward_address_attr') if forward_address_attr in user: if isinstance(user[forward_address_attr], basestring): forward_addresses = [ user[forward_address_attr] ] elif isinstance(user[forward_address_attr], str): forward_addresses = [ user[forward_address_attr] ] else: forward_addresses = user[forward_address_attr] if len(forward_addresses) == 0: forward_active = False forward_keepcopy_attr = conf.get('sieve', 'forward_keepcopy_active') if not forward_keepcopy_attr == None: if forward_keepcopy_attr in user: forward_keepcopy = utils.true_or_false(user[forward_keepcopy_attr]) else: forward_keepcopy = False forward_uce_attr = conf.get('sieve', 'forward_uce_active') if not forward_uce_attr == None: if forward_uce_attr in user: forward_uce = utils.true_or_false(user[forward_uce_attr]) else: forward_uce = False if vacation_active: mgmt_required_extensions.append('vacation') mgmt_required_extensions.append('envelope') if dtf_active: mgmt_required_extensions.append('fileinto') if forward_active and (len(forward_addresses) > 1 or forward_keepcopy): mgmt_required_extensions.append('copy') if sdf_filter: mgmt_required_extensions.append('fileinto') import sievelib.factory mgmt_script = sievelib.factory.FiltersSet("MANAGEMENT") for required_extension in mgmt_required_extensions: mgmt_script.require(required_extension) mgmt_script.require('fileinto') if vacation_active: if not vacation_react_domains == None and len(vacation_react_domains) > 0: mgmt_script.addfilter( 'vacation', [('envelope', ':domain', ":is", "from", vacation_react_domains)], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) elif not vacation_noreact_domains == None and len(vacation_noreact_domains) > 0: mgmt_script.addfilter( 'vacation', [('not', ('envelope', ':domain', ":is", "from", vacation_noreact_domains))], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) else: mgmt_script.addfilter( 'vacation', [('true',)], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) if forward_active: forward_rules = [] # Principle can be demonstrated by: # # python -c "print ','.join(['a','b','c'][:-1])" # for forward_copy in forward_addresses[:-1]: forward_rules.append(("redirect", ":copy", forward_copy)) if forward_keepcopy: # Principle can be demonstrated by: # # python -c "print ','.join(['a','b','c'][-1])" # if forward_uce: rule_name = 'forward-uce-keepcopy' else: rule_name = 'forward-keepcopy' forward_rules.append(("redirect", ":copy", forward_addresses[-1])) else: if forward_uce: rule_name = 'forward-uce' else: rule_name = 'forward' forward_rules.append(("redirect", forward_addresses[-1])) forward_rules.append(("stop")) if forward_uce: mgmt_script.addfilter(rule_name, ['true'], forward_rules) else: # NOTE: Messages with no X-Spam-Status header need to be matched # too, and this does exactly that. mgmt_script.addfilter(rule_name, [("not", ("X-Spam-Status", ":matches", "Yes,*"))], forward_rules) if sdf_filter: mgmt_script.addfilter('spam_delivery_folder', [("X-Spam-Status", ":matches", "Yes,*")], [("fileinto", "INBOX/Spam"), ("stop")]) if dtf_active: mgmt_script.addfilter('delivery_to_folder', ['true'], [("fileinto", dtf_folder)]) mgmt_script = mgmt_script.__str__() result = sieveclient.putscript("MANAGEMENT", mgmt_script) if not result: log.error(_("Uploading script MANAGEMENT failed for user %s") % (address)) else: log.debug(_("Uploading script MANAGEMENT for user %s succeeded") % (address), level=8) user_script = """# # User # require ["include"]; """ for script in scripts: if not script in [ "MASTER", "MANAGEMENT", "USER" ]: log.debug(_("Including script %s in USER (for user %s)") % (script,address) ,level=8) user_script = """%s include :personal "%s"; """ % (user_script, script) result = sieveclient.putscript("USER", user_script) if not result: log.error(_("Uploading script USER failed for user %s") % (address)) else: log.debug(_("Uploading script USER for user %s succeeded") % (address), level=8) result = sieveclient.putscript("MASTER", """# # MASTER # # This file is authoritative for your system and MUST BE KEPT ACTIVE. # # Altering it is likely to render your account dysfunctional and may # be violating your organizational or corporate policies. # # For more information on the mechanism and the conventions behind # this script, see http://wiki.kolab.org/KEP:14 # require ["include"]; # OPTIONAL: Includes for all or a group of users # include :global "all-users"; # include :global "this-group-of-users"; # The script maintained by the general management system include :personal "MANAGEMENT"; # The script(s) maintained by one or more editors available to the user include :personal "USER"; """) if not result: log.error(_("Uploading script MASTER failed for user %s") % (address)) else: log.debug(_("Uploading script MASTER for user %s succeeded") % (address), level=8) sieveclient.setactive("MASTER") diff --git a/pykolab/setup/setup_freebusy.py b/pykolab/setup/setup_freebusy.py index 3138226..09203a6 100644 --- a/pykolab/setup/setup_freebusy.py +++ b/pykolab/setup/setup_freebusy.py @@ -1,191 +1,197 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from ConfigParser import RawConfigParser +try: + from ConfigParser import RawConfigParser +except ImportError: + from configparser import RawConfigParser import os import sys import time import socket -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import components import pykolab from pykolab import utils from pykolab.constants import * from pykolab.translate import _ log = pykolab.getLogger('pykolab.setup') conf = pykolab.getConf() def __init__(): components.register( 'freebusy', execute, description=description(), after=['ldap'] ) def description(): return _("Setup Free/Busy.") def execute(*args, **kw): if not os.path.isfile('/etc/kolab-freebusy/config.ini') and not os.path.isfile('/etc/kolab-freebusy/config.ini.sample'): log.error(_("Free/Busy is not installed on this system")) return if not os.path.isfile('/etc/kolab-freebusy/config.ini'): os.rename('/etc/kolab-freebusy/config.ini.sample', '/etc/kolab-freebusy/config.ini') imap_backend = conf.get('kolab', 'imap_backend') admin_login = conf.get(imap_backend, 'admin_login') admin_password = conf.get(imap_backend, 'admin_password') imap_uri = conf.get(imap_backend, 'imap_uri') if imap_uri == None: imap_uri = conf.get(imap_backend, 'uri') scheme = None hostname = None port = None result = urlparse(imap_uri) if hasattr(result, 'hostname'): hostname = result.hostname if hasattr(result, 'port'): port = result.port if hasattr(result, 'scheme'): scheme = result.scheme else: scheme = imap_uri.split(':')[0] (hostname, port) = imap_uri.split('/')[2].split(':') if scheme == 'imaps' and (port == None or port == ''): port = 993 if scheme == None or scheme == '': scheme = 'imap' if port == None or port == '': port = 143 resources_imap_uri = '%s://%s:%s@%s:%s/%%kolabtargetfolder?acl=lrs' % (scheme, admin_login, admin_password, hostname, port) users_imap_uri = '%s://%%s:%s@%s:%s/?proxy_auth=%s' % (scheme, admin_password, hostname, port, admin_login) freebusy_settings = { 'httpauth': { 'type': 'ldap', 'host': conf.get('ldap', 'ldap_uri'), 'base_dn': conf.get('ldap', 'base_dn'), 'bind_dn': conf.get('ldap', 'service_bind_dn'), 'bind_pw': conf.get('ldap', 'service_bind_pw'), 'filter': '(&(objectClass=kolabInetOrgPerson)(|(mail=%s)(alias=%s)(uid=%s)))', }, 'trustednetworks': { 'allow': ','.join(get_local_ips()) }, 'directory "local"': { 'type': 'static', 'fbsource': 'file:/var/lib/kolab-freebusy/%s.ifb', }, 'directory "local-cache"': { 'type': 'static', 'fbsource': 'file:/var/cache/kolab-freebusy/%s.ifb', 'expires': '15m' }, 'directory "kolab-people"': { 'type': 'ldap', 'host': conf.get('ldap', 'ldap_uri'), 'base_dn': conf.get('ldap', 'base_dn'), 'bind_dn': conf.get('ldap', 'service_bind_dn'), 'bind_pw': conf.get('ldap', 'service_bind_pw'), 'filter': '(&(objectClass=kolabInetOrgPerson)(|(mail=%s)(alias=%s)))', 'attributes': 'mail', 'lc_attributes': 'mail', 'primary_domain': conf.get('kolab', 'primary_domain'), 'fbsource': users_imap_uri, 'cacheto': '/var/cache/kolab-freebusy/%s.ifb', 'expires': '15m', 'loglevel': 300, }, 'directory "kolab-resources"': { 'type': 'ldap', 'host': conf.get('ldap', 'ldap_uri'), 'base_dn': conf.get('ldap', 'resource_base_dn'), 'bind_dn': conf.get('ldap', 'service_bind_dn'), 'bind_pw': conf.get('ldap', 'service_bind_pw'), 'attributes': 'mail, kolabtargetfolder', 'filter': '(&(objectClass=kolabsharedfolder)(kolabfoldertype=event)(mail=%s))', 'primary_domain': conf.get('kolab', 'primary_domain'), 'fbsource': resources_imap_uri, 'cacheto': '/var/cache/kolab-freebusy/%s.ifb', 'expires': '15m', 'loglevel': 300, }, 'directory "kolab-resource-collections"': { 'type': 'ldap', 'host': conf.get('ldap', 'ldap_uri'), 'base_dn': conf.get('ldap', 'resource_base_dn'), 'bind_dn': conf.get('ldap', 'service_bind_dn'), 'bind_pw': conf.get('ldap', 'service_bind_pw'), 'filter': '(&(objectClass=kolabgroupofuniquenames)(mail=%s))', 'attributes': 'uniquemember', 'mail' 'resolve_dn': 'uniquemember', 'resolve_attribute': 'mail', 'primary_domain': conf.get('kolab', 'primary_domain'), 'fbsource': 'aggregate://%uniquemember', 'directories': 'kolab-resources', 'cacheto': '/var/cache/kolab-freebusy/%mail.ifb', 'expires': '15m', 'loglevel': 300, }, } cfg_parser = RawConfigParser() cfg_parser.read('/etc/kolab-freebusy/config.ini') for section in freebusy_settings: if len(freebusy_settings[section]) < 1: cfg_parser.remove_section(section) continue for key in freebusy_settings[section]: if not cfg_parser.has_section(section): cfg_parser.add_section(section) cfg_parser.set(section, key, freebusy_settings[section][key]) fp = open('/etc/kolab-freebusy/config.ini', "w+") cfg_parser.write(fp) fp.close() def get_local_ips(): ips = ['::1','127.0.0.1'] for family in [socket.AF_INET, socket.AF_INET6]: try: for ip in socket.getaddrinfo(socket.getfqdn(), None, family): if ip[4][0] not in ips: ips.append(ip[4][0]) except: pass return ips diff --git a/pykolab/setup/setup_mta.py b/pykolab/setup/setup_mta.py index 55b2695..a880a5a 100644 --- a/pykolab/setup/setup_mta.py +++ b/pykolab/setup/setup_mta.py @@ -1,540 +1,543 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from augeas import Augeas from Cheetah.Template import Template import os import shutil import subprocess import components import pykolab from pykolab import utils from pykolab.constants import * from pykolab.translate import _ log = pykolab.getLogger('pykolab.setup') conf = pykolab.getConf() def __init__(): components.register('mta', execute, description=description(), after=['ldap']) def description(): return _("Setup MTA.") def execute(*args, **kw): group_filter = conf.get('ldap','kolab_group_filter') if group_filter == None: group_filter = conf.get('ldap','group_filter') user_filter = conf.get('ldap','kolab_user_filter') if user_filter == None: user_filter = conf.get('ldap','user_filter') resource_filter = conf.get('ldap', 'resource_filter') sharedfolder_filter = conf.get('ldap', 'sharedfolder_filter') server_host = utils.parse_ldap_uri(conf.get('ldap', 'ldap_uri'))[1] files = { "/etc/postfix/ldap/local_recipient_maps.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(base_dn)s scope = sub domain = ldap:/etc/postfix/ldap/mydestination.cf bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s query_filter = (&(|(mail=%%s)(alias=%%s))(|%(kolab_user_filter)s%(kolab_group_filter)s%(resource_filter)s%(sharedfolder_filter)s)) result_attribute = mail """ % { "base_dn": conf.get('ldap', 'base_dn'), "server_host": server_host, "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), "kolab_user_filter": user_filter, "kolab_group_filter": group_filter, "resource_filter": resource_filter, "sharedfolder_filter": sharedfolder_filter, }, "/etc/postfix/ldap/mydestination.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(domain_base_dn)s scope = sub bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s query_filter = %(domain_filter)s result_attribute = %(domain_name_attribute)s """ % { "server_host": server_host, "domain_base_dn": conf.get('ldap', 'domain_base_dn'), "domain_filter": conf.get('ldap', 'domain_filter').replace('*', '%s'), "domain_name_attribute": conf.get('ldap', 'domain_name_attribute'), "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), }, "/etc/postfix/ldap/mailenabled_distgroups.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(group_base_dn)s scope = sub domain = ldap:/etc/postfix/ldap/mydestination.cf bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s # This finds the mail enabled distribution group LDAP entry query_filter = (&(|(mail=%%s)(alias=%%s))(objectClass=kolabgroupofuniquenames)(objectclass=groupofuniquenames)(!(objectclass=groupofurls))) # From this type of group, get all uniqueMember DNs special_result_attribute = uniqueMember # Only from those DNs, get the mail result_attribute = leaf_result_attribute = mail """ % { "server_host": server_host, "group_base_dn": conf.get('ldap', 'group_base_dn'), "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), }, "/etc/postfix/ldap/mailenabled_dynamic_distgroups.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(group_base_dn)s scope = sub domain = ldap:/etc/postfix/ldap/mydestination.cf bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s # This finds the mail enabled dynamic distribution group LDAP entry query_filter = (&(|(mail=%%s)(alias=%%s))(objectClass=kolabgroupofuniquenames)(objectClass=groupOfURLs)) # From this type of group, get all memberURL searches/references special_result_attribute = memberURL # Only from those DNs, get the mail result_attribute = leaf_result_attribute = mail """ % { "server_host": server_host, "group_base_dn": conf.get('ldap', 'group_base_dn'), "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), }, "/etc/postfix/ldap/transport_maps.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(base_dn)s scope = sub domain = ldap:/etc/postfix/ldap/mydestination.cf bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s query_filter = (&(|(mailAlternateAddress=%%s)(alias=%%s)(mail=%%s))(objectclass=kolabinetorgperson)) result_attribute = mail result_format = lmtp:unix:/var/lib/imap/socket/lmtp """ % { "base_dn": conf.get('ldap', 'base_dn'), "server_host": server_host, "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), }, "/etc/postfix/ldap/virtual_alias_maps.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(base_dn)s scope = sub domain = ldap:/etc/postfix/ldap/mydestination.cf bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s query_filter = (&(|(mail=%%s)(alias=%%s))(objectclass=kolabinetorgperson)) result_attribute = mail """ % { "base_dn": conf.get('ldap', 'base_dn'), "server_host": server_host, "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), }, "/etc/postfix/ldap/virtual_alias_maps_mailforwarding.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(base_dn)s scope = sub domain = ldap:/etc/postfix/ldap/mydestination.cf bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s query_filter = (&(|(mail=%%s)(alias=%%s))(objectclass=mailrecipient)(objectclass=inetorgperson)(mailforwardingaddress=*)) result_attribute = mailForwardingAddress """ % { "base_dn": conf.get('ldap', 'base_dn'), "server_host": server_host, "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), }, "/etc/postfix/ldap/virtual_alias_maps_sharedfolders.cf": """ server_host = %(server_host)s server_port = 389 version = 3 search_base = %(base_dn)s scope = sub domain = ldap:/etc/postfix/ldap/mydestination.cf bind_dn = %(service_bind_dn)s bind_pw = %(service_bind_pw)s query_filter = (&(|(mail=%%s)(alias=%%s))(objectclass=kolabsharedfolder)(kolabFolderType=mail)) result_attribute = kolabtargetfolder result_format = "shared+%%s" """ % { "base_dn": conf.get('ldap', 'base_dn'), "server_host": server_host, "service_bind_dn": conf.get('ldap', 'service_bind_dn'), "service_bind_pw": conf.get('ldap', 'service_bind_pw'), }, } if not os.path.isfile('/etc/postfix/main.cf'): if os.path.isfile('/usr/share/postfix/main.cf.debian'): shutil.copy( '/usr/share/postfix/main.cf.debian', '/etc/postfix/main.cf' ) if not os.path.isdir('/etc/postfix/ldap'): os.mkdir('/etc/postfix/ldap/', 0o770) for filename in files: fp = open(filename, 'w') fp.write(files[filename]) fp.close() fp = open('/etc/postfix/transport', 'a') fp.write("\n# Shared Folder Delivery for %(domain)s:\nshared@%(domain)s\t\tlmtp:unix:/var/lib/imap/socket/lmtp\n" % {'domain': conf.get('kolab', 'primary_domain')}) fp.close() subprocess.call(["postmap", "/etc/postfix/transport"]) postfix_main_settings = { "inet_interfaces": "all", "recipient_delimiter": "+", "local_recipient_maps": "ldap:/etc/postfix/ldap/local_recipient_maps.cf", "mydestination": "ldap:/etc/postfix/ldap/mydestination.cf", "transport_maps": "ldap:/etc/postfix/ldap/transport_maps.cf, hash:/etc/postfix/transport", "virtual_alias_maps": "$alias_maps, ldap:/etc/postfix/ldap/virtual_alias_maps.cf, ldap:/etc/postfix/ldap/virtual_alias_maps_mailforwarding.cf, ldap:/etc/postfix/ldap/virtual_alias_maps_sharedfolders.cf, ldap:/etc/postfix/ldap/mailenabled_distgroups.cf, ldap:/etc/postfix/ldap/mailenabled_dynamic_distgroups.cf", "smtpd_tls_auth_only": "yes", "smtpd_tls_security_level": "may", "smtp_tls_security_level": "may", "smtpd_sasl_auth_enable": "yes", "smtpd_sender_login_maps": "$local_recipient_maps", "smtpd_data_restrictions": "permit_mynetworks, check_policy_service unix:private/recipient_policy_incoming", "smtpd_recipient_restrictions": "permit_mynetworks, reject_unauth_pipelining, reject_rbl_client zen.spamhaus.org, reject_non_fqdn_recipient, reject_invalid_helo_hostname, reject_unknown_recipient_domain, reject_unauth_destination, check_policy_service unix:private/recipient_policy_incoming, permit", "smtpd_sender_restrictions": "permit_mynetworks, reject_sender_login_mismatch, check_policy_service unix:private/sender_policy_incoming", "submission_recipient_restrictions": "check_policy_service unix:private/submission_policy, permit_sasl_authenticated, reject", "submission_sender_restrictions": "reject_non_fqdn_sender, check_policy_service unix:private/submission_policy, permit_sasl_authenticated, reject", "submission_data_restrictions": "check_policy_service unix:private/submission_policy", "content_filter": "smtp-amavis:[127.0.0.1]:10024" } if os.path.isfile('/etc/pki/tls/certs/make-dummy-cert') and not os.path.isfile('/etc/pki/tls/private/localhost.pem'): subprocess.call(['/etc/pki/tls/certs/make-dummy-cert', '/etc/pki/tls/private/localhost.pem']) if os.path.isfile('/etc/pki/tls/private/localhost.pem'): postfix_main_settings['smtpd_tls_cert_file'] = "/etc/pki/tls/private/localhost.pem" postfix_main_settings['smtpd_tls_key_file'] = "/etc/pki/tls/private/localhost.pem" elif os.path.isfile('/etc/ssl/private/cyrus-imapd.pem'): # Debian 9 postfix_main_settings['smtpd_tls_cert_file'] = "/etc/ssl/private/cyrus-imapd.pem" postfix_main_settings['smtpd_tls_key_file'] = "/etc/ssl/private/cyrus-imapd.pem" # Copy header checks files for hc_file in [ 'inbound', 'internal', 'submission' ]: if not os.path.isfile("/etc/postfix/header_checks.%s" % (hc_file)): if os.path.isfile('/etc/kolab/templates/header_checks.%s' % (hc_file)): input_file = '/etc/kolab/templates/header_checks.%s' % (hc_file) elif os.path.isfile('/usr/share/kolab/templates/header_checks.%s' % (hc_file)): input_file = '/usr/share/kolab/templates/header_checks.%s' % (hc_file) elif os.path.isfile(os.path.abspath(os.path.join(__file__, '..', '..', '..', 'share', 'templates', 'header_checks.%s' % (hc_file)))): input_file = os.path.abspath(os.path.join(__file__, '..', '..', '..', 'share', 'templates', 'header_checks.%s' % (hc_file))) shutil.copy(input_file, "/etc/postfix/header_checks.%s" % (hc_file)) subprocess.call(["postmap", "/etc/postfix/header_checks.%s" % (hc_file)]) myaugeas = Augeas() setting_base = '/files/etc/postfix/main.cf/' for setting_key in postfix_main_settings: setting = os.path.join(setting_base,setting_key) current_value = myaugeas.get(setting) if current_value == None: try: myaugeas.set(setting, postfix_main_settings[setting_key]) except: insert_paths = myaugeas.match('/files/etc/postfix/main.cf/*') insert_path = insert_paths[(len(insert_paths)-1)] myaugeas.insert(insert_path, setting_key, False) log.debug(_("Setting key %r to %r") % (setting_key, postfix_main_settings[setting_key]), level=8) myaugeas.set(setting, postfix_main_settings[setting_key]) myaugeas.save() postfix_master_settings = { } if os.path.exists('/usr/lib/postfix/kolab_smtp_access_policy'): postfix_master_settings['kolab_sap_executable_path'] = '/usr/lib/postfix/kolab_smtp_access_policy' else: postfix_master_settings['kolab_sap_executable_path'] = '/usr/libexec/postfix/kolab_smtp_access_policy' template_file = None if os.path.isfile('/etc/kolab/templates/master.cf.tpl'): template_file = '/etc/kolab/templates/master.cf.tpl' elif os.path.isfile('/usr/share/kolab/templates/master.cf.tpl'): template_file = '/usr/share/kolab/templates/master.cf.tpl' elif os.path.isfile(os.path.abspath(os.path.join(__file__, '..', '..', '..', 'share', 'templates', 'master.cf.tpl'))): template_file = os.path.abspath(os.path.join(__file__, '..', '..', '..', 'share', 'templates', 'master.cf.tpl')) if not template_file == None: fp = open(template_file, 'r') template_definition = fp.read() fp.close() t = Template(template_definition, searchList=[postfix_master_settings]) fp = open('/etc/postfix/master.cf', 'w') fp.write(t.__str__()) fp.close() else: log.error(_("Could not write out Postfix configuration file /etc/postfix/master.cf")) return if os.path.isdir('/etc/postfix/sasl/'): fp = open('/etc/postfix/sasl/smtpd.conf', 'w') fp.write("pwcheck_method: saslauthd\n") fp.write("mech_list: plain login\n") fp.close() amavisd_settings = { 'ldap_server': '%(server_host)s', 'ldap_bind_dn': conf.get('ldap', 'service_bind_dn'), 'ldap_bind_pw': conf.get('ldap', 'service_bind_pw'), 'primary_domain': conf.get('kolab', 'primary_domain'), 'ldap_filter': "(|(mail=%m)(alias=%m))", 'ldap_base_dn': conf.get('ldap', 'base_dn'), 'clamdsock': '/var/spool/amavisd/clamd.sock', } template_file = None # On RPM installations, Amavis configuration is contained within a single file. amavisconf_paths = [ "/etc/amavisd.conf", "/etc/amavis/amavisd.conf", "/etc/amavisd/amavisd.conf" ] amavis_conf = '' for amavisconf_path in amavisconf_paths: if os.path.isfile(amavisconf_path): amavis_conf = amavisconf_path break if os.path.isfile(amavis_conf): if os.path.isfile('/etc/kolab/templates/amavisd.conf.tpl'): template_file = '/etc/kolab/templates/amavisd.conf.tpl' elif os.path.isfile('/usr/share/kolab/templates/amavisd.conf.tpl'): template_file = '/usr/share/kolab/templates/amavisd.conf.tpl' elif os.path.isfile(os.path.abspath(os.path.join(__file__, '..', '..', '..', 'share', 'templates', 'amavisd.conf.tpl'))): template_file = os.path.abspath(os.path.join(__file__, '..', '..', '..', 'share', 'templates', 'amavisd.conf.tpl')) if not template_file == None: fp = open(template_file, 'r') template_definition = fp.read() fp.close() if os.path.isfile('/etc/clamd.d/amavisd.conf'): amavisdconf_content = file('/etc/clamd.d/amavisd.conf') for line in amavisdconf_content: if line.startswith('LocalSocket'): amavisd_settings['clamdsock'] = line[len('LocalSocket '):].strip() t = Template(template_definition, searchList=[amavisd_settings]) fp = None fp = open(amavis_conf, 'w') if not fp == None: fp.write(t.__str__()) fp.close() else: log.error(_("Could not write out Amavis configuration file amavisd.conf")) return # On APT installations, /etc/amavis/conf.d/ is a directory with many more files. # # Somebody could work on enhancement request #1080 to configure LDAP lookups, # while really it isn't required. else: log.info(_("Not writing out any configuration for Amavis.")) # On debian wheezy amavisd-new expects '/etc/mailname' - possibly remediable through # the #1080 enhancement mentioned above, but here's a quick fix. f = open('/etc/mailname','w') f.writelines(conf.get('kolab', 'primary_domain')) f.close() if os.path.isfile('/etc/default/spamassassin'): myaugeas = Augeas() setting = os.path.join('/files/etc/default/spamassassin','ENABLED') if not myaugeas.get(setting) == '1': myaugeas.set(setting,'1') myaugeas.save() myaugeas.close() if os.path.isfile('/etc/default/wallace'): myaugeas = Augeas() setting = os.path.join('/files/etc/default/wallace','START') if not myaugeas.get(setting) == 'yes': myaugeas.set(setting,'yes') myaugeas.save() myaugeas.close() if os.path.isfile('/usr/lib/systemd/system/clamd@.service'): - from ConfigParser import SafeConfigParser + try: + from ConfigParser import SafeConfigParser + except ImportError: + from configparser import SafeConfigParser unitfile = SafeConfigParser() unitfile.optionxform = str unitfile.read('/usr/lib/systemd/system/clamd@.service') if not unitfile.has_section('Install'): unitfile.add_section('Install') if not unitfile.has_option('Install', 'WantedBy'): unitfile.set('Install', 'WantedBy', 'multi-user.target') with open('/etc/systemd/system/clamd@.service', 'wb') as f: unitfile.write(f) log.info(_("Configuring and refreshing Anti-Virus...")) if os.path.isfile('/etc/kolab/templates/freshclam.conf.tpl'): shutil.copy( '/etc/kolab/templates/freshclam.conf.tpl', '/etc/freshclam.conf' ) elif os.path.isfile('/usr/share/kolab/templates/freshclam.conf.tpl'): shutil.copy( '/usr/share/kolab/templates/freshclam.conf.tpl', '/etc/freshclam.conf' ) else: log.error(_("Could not find a ClamAV update configuration file")) if os.path.isfile('/etc/freshclam.conf'): subprocess.call([ '/usr/bin/freshclam', '--quiet', '--datadir="/var/lib/clamav"' ]) amavisservice = 'amavisd.service' clamavservice = 'clamd@amavisd.service' if os.path.isfile('/usr/lib/systemd/system/amavis.service'): amavisservice = 'amavis.service' if os.path.isfile('/lib/systemd/system/amavis.service'): amavisservice = 'amavis.service' if os.path.isfile('/etc/init.d/amavis'): amavisservice = 'amavis.service' if os.path.isfile('/usr/lib/systemd/system/clamd.service'): clamavservice = 'clamd.service' if os.path.isfile('/lib/systemd/system/clamd.service'): clamavservice = 'clamd.service' if os.path.isfile('/lib/systemd/system/clamav-daemon.service'): clamavservice = 'clamav-daemon.service' if os.path.isfile('/bin/systemctl'): subprocess.call(['systemctl', 'restart', 'postfix.service']) subprocess.call(['systemctl', 'restart', amavisservice]) subprocess.call(['systemctl', 'restart', clamavservice]) subprocess.call(['systemctl', 'restart', 'wallace.service']) elif os.path.isfile('/sbin/service'): subprocess.call(['service', 'postfix', 'restart']) subprocess.call(['service', 'amavisd', 'restart']) subprocess.call(['service', 'clamd.amavisd', 'restart']) subprocess.call(['service', 'wallace', 'restart']) elif os.path.isfile('/usr/sbin/service'): subprocess.call(['/usr/sbin/service','postfix','restart']) subprocess.call(['/usr/sbin/service','amavis','restart']) subprocess.call(['/usr/sbin/service','clamav-daemon','restart']) subprocess.call(['/usr/sbin/service','wallace','restart']) else: log.error(_("Could not start the postfix, clamav and amavisd services services.")) if os.path.isfile('/bin/systemctl'): subprocess.call(['systemctl', 'enable', 'postfix.service']) subprocess.call(['systemctl', 'enable', amavisservice]) subprocess.call(['systemctl', 'enable', clamavservice]) subprocess.call(['systemctl', 'enable', 'wallace.service']) elif os.path.isfile('/sbin/chkconfig'): subprocess.call(['chkconfig', 'postfix', 'on']) subprocess.call(['chkconfig', 'amavisd', 'on']) subprocess.call(['chkconfig', 'clamd.amavisd', 'on']) subprocess.call(['chkconfig', 'wallace', 'on']) elif os.path.isfile('/usr/sbin/update-rc.d'): subprocess.call(['/usr/sbin/update-rc.d', 'postfix', 'defaults']) subprocess.call(['/usr/sbin/update-rc.d', 'amavis', 'defaults']) subprocess.call(['/usr/sbin/update-rc.d', 'clamav-daemon', 'defaults']) subprocess.call(['/usr/sbin/update-rc.d', 'wallace', 'defaults']) else: log.error(_("Could not configure to start on boot, the " + \ "postfix, clamav and amavisd services.")) diff --git a/pykolab/wap_client/__init__.py b/pykolab/wap_client/__init__.py index f3cf1d7..f8427c0 100644 --- a/pykolab/wap_client/__init__.py +++ b/pykolab/wap_client/__init__.py @@ -1,661 +1,664 @@ import json import httplib import urllib import sys -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import pykolab from pykolab import utils from pykolab.translate import _ log = pykolab.getLogger('pykolab.wap_client') conf = pykolab.getConf() if not hasattr(conf, 'defaults'): conf.finalize_conf() API_HOSTNAME = "localhost" API_SCHEME = "http" API_PORT = 80 API_SSL = False API_BASE = "/kolab-webadmin/api/" kolab_wap_url = conf.get('kolab_wap', 'api_url') if not kolab_wap_url == None: result = urlparse(kolab_wap_url) else: result = None if hasattr(result, 'scheme') and result.scheme == 'https': API_SSL = True API_PORT = 443 if hasattr(result, 'hostname'): API_HOSTNAME = result.hostname if hasattr(result, 'port'): API_PORT = result.port if hasattr(result, 'path'): API_BASE = result.path session_id = None conn = None def authenticate(username=None, password=None, domain=None): global session_id if username == None: username = conf.get('ldap', 'bind_dn') if password == None: password = conf.get('ldap', 'bind_pw') if domain == None: domain = conf.get('kolab', 'primary_domain') post = json.dumps( { 'username': username, 'password': password, 'domain': domain } ) response = request('POST', "system.authenticate", post=post) if not response: return False if 'session_token' in response: session_id = response['session_token'] return True def connect(uri=None): global conn, API_SSL, API_PORT, API_HOSTNAME, API_BASE if not uri == None: result = urlparse(uri) if hasattr(result, 'scheme') and result.scheme == 'https': API_SSL = True API_PORT = 443 if hasattr(result, 'hostname'): API_HOSTNAME = result.hostname if hasattr(result, 'port'): API_PORT = result.port if hasattr(result, 'path'): API_BASE = result.path if conn == None: if API_SSL: conn = httplib.HTTPSConnection(API_HOSTNAME, API_PORT) else: conn = httplib.HTTPConnection(API_HOSTNAME, API_PORT) conn.connect() return conn def disconnect(quit=False): global conn, session_id if quit and session_id: request('GET', 'system.quit') session_id = None if conn: conn.close() conn = None def domain_add(domain, aliases=[]): dna = conf.get('ldap', 'domain_name_attribute') post = json.dumps({ dna: [ domain ] + aliases }) return request('POST', 'domain.add', post=post) def domain_delete(domain, force=False): domain_id, domain_attrs = domain_find(domain).popitem() param = {} param['id'] = domain_id if force: param['force'] = force post = json.dumps(param) return request('POST', 'domain.delete', post=post) def domain_find(domain): dna = conf.get('ldap', 'domain_name_attribute') get = { dna: domain } return request('GET', 'domain.find', get=get) def domain_info(domain): domain_id, domain_attrs = domain_find(domain) get = { 'id': domain_id } return request('GET', 'domain.info', get=get) def domains_capabilities(): return request('GET', 'domains.capabilities') def domains_list(): return request('GET', 'domains.list') def form_value_generate(params): post = json.dumps(params) return request('POST', 'form_value.generate', post=post) def form_value_generate_password(*args, **kw): return request('GET', 'form_value.generate_password') def form_value_list_options(object_type, object_type_id, attribute): post = json.dumps( { 'object_type': object_type, 'type_id': object_type_id, 'attribute': attribute } ) return request('POST', 'form_value.list_options', post=post) def form_value_select_options(object_type, object_type_id, attribute): post = json.dumps( { 'object_type': object_type, 'type_id': object_type_id, 'attributes': [ attribute ] } ) return request('POST', 'form_value.select_options', post=post) def get_group_input(): group_types = group_types_list() if len(group_types) > 1: for key in group_types: if not key == "status": print("%s) %s" % (key,group_types[key]['name'])) group_type_id = utils.ask_question("Please select the group type") elif len(group_types) > 0: print("Automatically selected the only group type available") group_type_id = group_types.keys()[0] else: print("No group types available") sys.exit(1) if group_type_id in group_types: group_type_info = group_types[group_type_id]['attributes'] else: print("No such group type") sys.exit(1) params = { 'group_type_id': group_type_id } for attribute in group_type_info['form_fields']: params[attribute] = utils.ask_question(attribute) for attribute in group_type_info['auto_form_fields']: exec("retval = group_form_value_generate_%s(params)" % (attribute)) params[attribute] = retval[attribute] return params def get_user_input(): user_types = user_types_list() if user_types['count'] > 1: print("") for key in user_types['list']: if not key == "status": print("%s) %s" % (key,user_types['list'][key]['name'])) print("") user_type_id = utils.ask_question("Please select the user type") elif user_types['count'] > 0: print("Automatically selected the only user type available") user_type_id = user_types['list'].keys()[0] else: print("No user types available") sys.exit(1) if user_type_id in user_types['list']: user_type_info = user_types['list'][user_type_id]['attributes'] else: print("No such user type") sys.exit(1) params = { 'object_type': 'user', 'type_id': user_type_id } must_attrs = [] may_attrs = [] for attribute in user_type_info['form_fields']: if isinstance(user_type_info['form_fields'][attribute], dict): if 'optional' in user_type_info['form_fields'][attribute] and user_type_info['form_fields'][attribute]['optional']: may_attrs.append(attribute) else: must_attrs.append(attribute) else: must_attrs.append(attribute) for attribute in must_attrs: if isinstance(user_type_info['form_fields'][attribute], dict) and \ 'type' in user_type_info['form_fields'][attribute]: if user_type_info['form_fields'][attribute]['type'] == 'select': if 'values' not in user_type_info['form_fields'][attribute]: attribute_values = form_value_select_options('user', user_type_id, attribute) default = '' if 'default' in attribute_values[attribute]: default = attribute_values[attribute]['default'] params[attribute] = utils.ask_menu( "Choose the %s value" % (attribute), attribute_values[attribute]['list'], default=default ) else: default = '' if 'default' in user_type_info['form_fields'][attribute]: default = user_type_info['form_fields'][attribute]['default'] params[attribute] = utils.ask_menu( "Choose the %s value" % (attribute), user_type_info['form_fields'][attribute]['values'], default=default ) else: params[attribute] = utils.ask_question(attribute) else: params[attribute] = utils.ask_question(attribute) for attribute in user_type_info['fields']: params[attribute] = user_type_info['fields'][attribute] exec("retval = user_form_value_generate(params)") print(retval) return params def group_add(params=None): if params == None: params = get_group_input() post = json.dumps(params) return request('POST', 'group.add', post=post) def group_delete(params=None): if params == None: params = { 'id': utils.ask_question("Name of group to delete", "group") } post = json.dumps(params) return request('POST', 'group.delete', post=post) def group_form_value_generate_mail(params=None): if params == None: params = get_user_input() params = json.dumps(params) return request('POST', 'group_form_value.generate_mail', params) def group_find(params=None): post = { 'search': { 'params': {} } } for (k,v) in params.items(): post['search']['params'][k] = { 'value': v, 'type': 'exact' } return request('POST', 'group.find', post=json.dumps(post)) def group_info(group=None): if group == None: group = utils.ask_question("group DN") return request('GET', 'group.info', get={ 'id': group }) def group_members_list(group=None): if group == None: group = utils.ask_question("Group email address") group = request('GET', 'group.members_list?group=%s' % (group)) return group def group_types_list(): return request('GET', 'group_types.list') def groups_list(params={}): return request('POST', 'groups.list', post=json.dumps(params)) def ou_add(params={}): return request('POST', 'ou.add', post=json.dumps(params)) def ou_delete(params={}): return request('POST', 'ou.delete', post=json.dumps(params)) def ou_edit(params={}): return request('POST', 'ou.edit', post=json.dumps(params)) def ou_find(params=None): post = { 'search': { 'params': {} } } for (k,v) in params.items(): post['search']['params'][k] = { 'value': v, 'type': 'exact' } return request('POST', 'ou.find', post=json.dumps(post)) def ou_info(ou): _params = { 'id': ou } ou = request('GET', 'ou.info', get=_params) return ou def ous_list(params={}): return request('POST', 'ous.list', post=json.dumps(params)) def request(method, api_uri, get=None, post=None, headers={}): response_data = request_raw(method, api_uri, get, post, headers) if response_data['status'] == "OK": del response_data['status'] return response_data['result'] else: print("%s: %s (code %s)" % (response_data['status'], response_data['reason'], response_data['code'])) return False def request_raw(method, api_uri, get=None, post=None, headers={}, isretry=False): global session_id if not session_id == None: headers["X-Session-Token"] = session_id reconnect = False conn = connect() if conf.debuglevel > 8: conn.set_debuglevel(9) if not get == None: _get = "?%s" % (urllib.urlencode(get)) else: _get = "" log.debug(_("Requesting %r with params %r") % ("%s/%s" % (API_BASE,api_uri), (get, post)), level=8) try: conn.request(method.upper(), "%s/%s%s" % (API_BASE, api_uri, _get), post, headers) response = conn.getresponse() data = response.read() log.debug(_("Got response: %r") % (data), level=8) except (httplib.BadStatusLine, httplib.CannotSendRequest) as e: if isretry: raise e log.info(_("Connection error: %r; re-connecting..."), e) reconnect = True # retry with a new connection if reconnect: disconnect() return request_raw(method, api_uri, get, post, headers, True) try: response_data = json.loads(data) except ValueError: # Some data is not JSON log.error(_("Response data is not JSON")) return response_data def resource_add(params=None): if params == None: params = get_user_input() return request('POST', 'resource.add', post=json.dumps(params)) def resource_delete(params=None): if params == None: params = { 'id': utils.ask_question("Resource DN to delete", "resource") } return request('POST', 'resource.delete', post=json.dumps(params)) def resource_find(params=None): post = { 'search': { 'params': {} } } for (k,v) in params.items(): post['search']['params'][k] = { 'value': v, 'type': 'exact' } return request('POST', 'resource.find', post=json.dumps(post)) def resource_info(resource=None): if resource == None: resource = utils.ask_question("Resource DN") return request('GET', 'resource.info', get={ 'id': resource }) def resource_types_list(): return request('GET', 'resource_types.list') def resources_list(params={}): return request('POST', 'resources.list', post=json.dumps(params)) def role_add(params=None): if params == None: role_name = utils.ask_question("Role name") params = { 'cn': role_name } params = json.dumps(params) return request('POST', 'role.add', params) def role_capabilities(): return request('GET', 'role.capabilities') def role_delete(params=None): if params == None: role_name = utils.ask_question("Role name") role = role_find_by_attribute({'cn': role_name}) params = { 'role': role.keys()[0] } if 'role' not in params: role = role_find_by_attribute(params) params = { 'role': role.keys()[0] } post = json.dumps(params) return request('POST', 'role.delete', post=post) def role_find_by_attribute(params=None): if params == None: role_name = utils.ask_question("Role name") else: role_name = params['cn'] get = { 'cn': role_name } role = request('GET', 'role.find_by_attribute', get=get) return role def role_info(role_name): role = role_find_by_attribute({'cn': role_name}) get = { 'role': role['id'] } role = request('GET', 'role.info', get=get) return role def roles_list(): return request('GET', 'roles.list') def sharedfolder_add(params=None): if params == None: params = get_user_input() return request('POST', 'sharedfolder.add', post=json.dumps(params)) def sharedfolder_delete(params=None): if params == None: params = { 'id': utils.ask_question("Shared Folder DN to delete", "sharedfolder") } return request('POST', 'sharedfolder.delete', post=json.dumps(params)) def sharedfolders_list(params={}): return request('POST', 'sharedfolders.list', post=json.dumps(params)) def system_capabilities(domain=None): return request('GET', 'system.capabilities', get={'domain':domain}) def system_get_domain(): return request('GET', 'system.get_domain') def system_select_domain(domain=None): if domain == None: domain = utils.ask_question("Domain name") get = { 'domain': domain } return request('GET', 'system.select_domain', get=get) def user_add(params=None): if params == None: params = get_user_input() params = json.dumps(params) return request('POST', 'user.add', post=params) def user_delete(params=None): if params == None: params = { 'id': utils.ask_question("Username for user to delete", "user") } post = json.dumps(params) return request('POST', 'user.delete', post=post) def user_edit(user = None, attributes={}): if user == None: get = { 'id': utils.ask_question("Username for user to edit", "user") } else: get = { 'id': user } user_info = request('GET', 'user.info', get=get) for attribute in attributes: user_info[attribute] = attributes[attribute] post = json.dumps(user_info) user_edit = request('POST', 'user.edit', get=get, post=post) return user_edit def user_find(attribs=None): if attribs == None: post = { 'search': { 'params': { utils.ask_question("Attribute") : { 'value': utils.ask_question("value"), 'type': 'exact' } } } } else: post = { 'search': { 'params': {} } } for (k,v) in attribs.items(): post['search']['params'][k] = { 'value': v, 'type': 'exact' } post = json.dumps(post) user = request('POST', 'user.find', post=post) return user def user_form_value_generate(params=None): if params == None: params = get_user_input() post = json.dumps(params) return request('POST', 'form_value.generate', post=post) def user_form_value_generate_uid(params=None): if params == None: params = get_user_input() params = json.dumps(params) return request('POST', 'form_value.generate_uid', params) def user_form_value_generate_userpassword(*args, **kw): result = form_value_generate_password() return { 'userpassword': result['password'] } def user_info(user=None): if user == None: user = utils.ask_question("User email address") _params = { 'id': user } user = request('GET', 'user.info', get=_params) return user def users_list(params={}): return request('POST', 'users.list', post=json.dumps(params)) def user_types_list(): return request('GET', 'user_types.list') diff --git a/saslauthd/__init__.py b/saslauthd/__init__.py index d2459ad..73beb93 100644 --- a/saslauthd/__init__.py +++ b/saslauthd/__init__.py @@ -1,381 +1,383 @@ # Copyright 2010-2016 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """ SASL authentication daemon for multi-domain Kolab deployments. The SASL authentication daemon can use the domain name space or realm in the login credentials to determine the backend authentication database, and authenticate the credentials supplied against that backend. """ from __future__ import print_function from optparse import OptionParser -from ConfigParser import SafeConfigParser - +try: + from ConfigParser import SafeConfigParser +except ImportError: + from configparser import SafeConfigParser import grp import os import pwd import shutil import sys import time import traceback import pykolab from pykolab import utils from pykolab.auth import Auth from pykolab.constants import * from pykolab.translate import _ log = pykolab.getLogger('saslauthd') conf = pykolab.getConf() class SASLAuthDaemon(object): def __init__(self): daemon_group = conf.add_cli_parser_option_group(_("Daemon Options")) daemon_group.add_option( "--fork", dest = "fork_mode", action = "store_true", default = False, help = _("Fork to the background.") ) daemon_group.add_option( "-p", "--pid-file", dest = "pidfile", action = "store", default = "/var/run/kolab-saslauthd/kolab-saslauthd.pid", help = _("Path to the PID file to use.") ) daemon_group.add_option( "-s", "--socket", dest = "socketfile", action = "store", default = "/var/run/saslauthd/mux", help = _("Socket file to bind to.") ) daemon_group.add_option( "-u", "--user", dest = "process_username", action = "store", default = "kolab", help = _("Run as user USERNAME"), metavar = "USERNAME" ) daemon_group.add_option( "-g", "--group", dest = "process_groupname", action = "store", default = "kolab", help = _("Run as group GROUPNAME"), metavar = "GROUPNAME" ) conf.finalize_conf() try: utils.ensure_directory( os.path.dirname(conf.pidfile), conf.process_username, conf.process_groupname ) except Exception as errmsg: log.error(_("Could not create %r: %r") % (os.path.dirname(conf.pidfile), errmsg)) sys.exit(1) self.thread_count = 0 def run(self): """ Run the SASL authentication daemon. """ exitcode = 0 self._ensure_socket_dir() self._drop_privileges() try: pid = os.getpid() if conf.fork_mode: pid = os.fork() if pid > 0 and not conf.fork_mode: self.do_saslauthd() elif pid > 0: sys.exit(0) else: # Give up the session, all control, # all open file descriptors, see #5151 os.chdir("/") old_umask = os.umask(0) os.setsid() pid = os.fork() if pid > 0: sys.exit(0) sys.stderr.flush() sys.stdout.flush() os.close(0) os.close(1) os.close(2) os.umask(old_umask) self.thread_count += 1 log.remove_stdout_handler() self.set_signal_handlers() self.write_pid() self.do_saslauthd() except SystemExit as e: exitcode = e except KeyboardInterrupt: exitcode = 1 log.info(_("Interrupted by user")) except AttributeError: exitcode = 1 traceback.print_exc() print(_("Traceback occurred, please report a " + "bug at https://issues.kolab.org"), file=sys.stderr) except TypeError as e: exitcode = 1 traceback.print_exc() log.error(_("Type Error: %s") % e) except: exitcode = 2 traceback.print_exc() print(_("Traceback occurred, please report a " + "bug at https://issues.kolab.org"), file=sys.stderr) sys.exit(exitcode) def do_saslauthd(self): """ Create the actual listener socket, and handle the authentication. The actual authentication handling is passed on to the appropriate backend authentication classes through the more generic Auth(). """ import binascii import socket import struct s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) # TODO: The saslauthd socket path could be a setting. try: os.remove(conf.socketfile) except: # TODO: Do the "could not remove, could not start" dance pass s.bind(conf.socketfile) os.chmod(conf.socketfile, 0o777) s.listen(5) while 1: max_tries = 20 cur_tries = 0 bound = False while not bound: cur_tries += 1 try: (clientsocket, address) = s.accept() bound = True except Exception as errmsg: log.error( _("kolab-saslauthd could not accept " + "connections on socket: %r") % (errmsg) ) if cur_tries >= max_tries: log.fatal(_("Maximum tries exceeded, exiting")) sys.exit(1) time.sleep(1) received = clientsocket.recv(4096) login = [] start = 0 end = 2 while end < len(received): (length,) = struct.unpack("!H", received[start:end]) start += 2 end += length (value,) = struct.unpack("!%ds" % (length), received[start:end]) start += length end = start + 2 login.append(value) if len(login) == 4: realm = login[3] elif len(login[0].split('@')) > 1: realm = login[0].split('@')[1] else: realm = conf.get('kolab', 'primary_domain') auth = Auth(domain=realm) auth.connect() success = False try: success = auth.authenticate(login) except: success = False if success: # #1170: Catch broken pipe error (incomplete authentication request) try: clientsocket.send(struct.pack("!H2s", 2, "OK")) except: pass else: # #1170: Catch broken pipe error (incomplete authentication request) try: clientsocket.send(struct.pack("!H2s", 2, "NO")) except: pass clientsocket.close() auth.disconnect() def reload_config(self, *args, **kw): pass def remove_pid(self, *args, **kw): if os.access(conf.pidfile, os.R_OK): os.remove(conf.pidfile) raise SystemExit def set_signal_handlers(self): import signal signal.signal(signal.SIGHUP, self.reload_config) signal.signal(signal.SIGTERM, self.remove_pid) def write_pid(self): pid = os.getpid() fp = open(conf.pidfile, 'w') fp.write("%d\n" % (pid)) fp.close() def _ensure_socket_dir(self): utils.ensure_directory( os.path.dirname(conf.socketfile), conf.process_username, conf.process_groupname ) def _drop_privileges(self): try: try: (ruid, euid, suid) = os.getresuid() (rgid, egid, sgid) = os.getresgid() except AttributeError: ruid = os.getuid() rgid = os.getgid() if ruid == 0: # Means we can setreuid() / setregid() / setgroups() if rgid == 0: # Get group entry details try: ( group_name, group_password, group_gid, group_members ) = grp.getgrnam(conf.process_groupname) except KeyError: print(_("Group %s does not exist") % ( conf.process_groupname ), file=sys.stderr) sys.exit(1) # Set real and effective group if not the same as current. if not group_gid == rgid: log.debug( _("Switching real and effective group id to %d") % ( group_gid ), level=8 ) os.setregid(group_gid, group_gid) if ruid == 0: # Means we haven't switched yet. try: ( user_name, user_password, user_uid, user_gid, user_gecos, user_homedir, user_shell ) = pwd.getpwnam(conf.process_username) except KeyError: print(_("User %s does not exist") % ( conf.process_username ), file=sys.stderr) sys.exit(1) # Set real and effective user if not the same as current. if not user_uid == ruid: log.debug( _("Switching real and effective user id to %d") % ( user_uid ), level=8 ) os.setreuid(user_uid, user_uid) except: log.error(_("Could not change real and effective uid and/or gid")) diff --git a/tests/functional/test_wap_client/test_007_policy_uid.py b/tests/functional/test_wap_client/test_007_policy_uid.py index a10eeb9..eac4022 100644 --- a/tests/functional/test_wap_client/test_007_policy_uid.py +++ b/tests/functional/test_wap_client/test_007_policy_uid.py @@ -1,175 +1,178 @@ -from ConfigParser import RawConfigParser +try: + from ConfigParser import RawConfigParser +except ImportError: + from configparser import RawConfigParser import time import unittest import pykolab from pykolab import wap_client from pykolab.auth import Auth from pykolab.imap import IMAP conf = pykolab.getConf() class TestPolicyUid(unittest.TestCase): def remove_option(self, section, option): self.config.remove_option(section, option) fp = open(conf.config_file, "w") self.config.write(fp) fp.close() def set(self, section, option, value): self.config.set(section, option, value) fp = open(conf.config_file, "w") self.config.write(fp) fp.close() @classmethod def setup_class(self, *args, **kw): self.config = RawConfigParser() self.config.read(conf.config_file) from tests.functional.purge_users import purge_users purge_users() self.user = { 'local': 'john.doe', 'domain': 'example.org' } self.login = conf.get('ldap', 'bind_dn') self.password = conf.get('ldap', 'bind_pw') self.domain = conf.get('kolab', 'primary_domain') result = wap_client.authenticate(self.login, self.password, self.domain) @classmethod def teardown_class(self, *args, **kw): self.config.remove_option('example.org', 'policy_uid') fp = open(conf.config_file, "w") self.config.write(fp) fp.close() from tests.functional.purge_users import purge_users purge_users() def test_001_default(self): from tests.functional.user_add import user_add user_add("John", "Doe") from tests.functional.synchronize import synchronize_once synchronize_once() auth = Auth() auth.connect() user = auth.find_recipient('john.doe@example.org') user_info = wap_client.user_info(user) self.assertEqual(user_info['uid'], "doe") from tests.functional.purge_users import purge_users purge_users() def test_002_givenname_dot_surname(self): self.set('example.org', 'policy_uid', '%(givenname)s.%(surname)s') from tests.functional.user_add import user_add user_add("John", "Doe") from tests.functional.synchronize import synchronize_once synchronize_once() auth = Auth() auth.connect() user = auth.find_recipient('john.doe@example.org') user_info = wap_client.user_info(user) self.assertEqual(user_info['uid'], "John.Doe") from tests.functional.purge_users import purge_users purge_users() def test_003_givenname_fc_dot_surname(self): self.set('example.org', 'policy_uid', "'%(givenname)s'[0:1].%(surname)s") from tests.functional.user_add import user_add user_add("John", "Doe") from tests.functional.synchronize import synchronize_once synchronize_once() auth = Auth() auth.connect() user = auth.find_recipient('john.doe@example.org') user_info = wap_client.user_info(user) self.assertEqual(user_info['uid'], "J.Doe") from tests.functional.purge_users import purge_users purge_users() def test_004_givenname(self): self.set('example.org', 'policy_uid', '%(givenname)s') from tests.functional.user_add import user_add user_add("John", "Doe") from tests.functional.synchronize import synchronize_once synchronize_once() auth = Auth() auth.connect() user = auth.find_recipient('john.doe@example.org') user_info = wap_client.user_info(user) self.assertEqual(user_info['uid'], "John") from tests.functional.purge_users import purge_users purge_users() def test_005_lowercase_givenname(self): self.set('example.org', 'policy_uid', '%(givenname)s.lower()') from tests.functional.user_add import user_add user_add("John", "Doe") from tests.functional.synchronize import synchronize_once synchronize_once() auth = Auth() auth.connect() user = auth.find_recipient('john.doe@example.org') user_info = wap_client.user_info(user) self.assertEqual(user_info['uid'], "john") from tests.functional.purge_users import purge_users purge_users() def test_006_lowercase_givenname_surname(self): self.set('example.org', 'policy_uid', "%(givenname)s.lower().%(surname)s.lower()") from tests.functional.user_add import user_add user_add("John", "Doe") from tests.functional.synchronize import synchronize_once synchronize_once() auth = Auth() auth.connect() user = auth.find_recipient('john.doe@example.org') user_info = wap_client.user_info(user) self.assertEqual(user_info['uid'], "john.doe") from tests.functional.purge_users import purge_users purge_users() diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py index 63d8879..4ad224e 100644 --- a/wallace/module_invitationpolicy.py +++ b/wallace/module_invitationpolicy.py @@ -1,1480 +1,1483 @@ # -*- coding: utf-8 -*- # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import datetime import os import random import signal import tempfile import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import urllib import hashlib import traceback import re from email import message_from_string from email.parser import Parser from email.utils import formataddr from email.utils import getaddresses import modules import pykolab import kolabformat from pykolab import utils from pykolab.auth import Auth from pykolab.conf import Conf from pykolab.imap import IMAP from pykolab.xml import to_dt from pykolab.xml import utils as xmlutils from pykolab.xml import todo_from_message from pykolab.xml import event_from_message from pykolab.xml import participant_status_label from pykolab.itip import objects_from_message from pykolab.itip import check_event_conflict from pykolab.itip import send_reply from pykolab.translate import _ # define some contstants used in the code below ACT_MANUAL = 1 ACT_ACCEPT = 2 ACT_DELEGATE = 4 ACT_REJECT = 8 ACT_UPDATE = 16 ACT_CANCEL_DELETE = 32 ACT_SAVE_TO_FOLDER = 64 COND_IF_AVAILABLE = 128 COND_IF_CONFLICT = 256 COND_TENTATIVE = 512 COND_NOTIFY = 1024 COND_FORWARD = 2048 COND_TYPE_EVENT = 4096 COND_TYPE_TASK = 8192 COND_TYPE_ALL = COND_TYPE_EVENT + COND_TYPE_TASK ACT_TENTATIVE = ACT_ACCEPT + COND_TENTATIVE ACT_UPDATE_AND_NOTIFY = ACT_UPDATE + COND_NOTIFY ACT_SAVE_AND_FORWARD = ACT_SAVE_TO_FOLDER + COND_FORWARD ACT_CANCEL_DELETE_AND_NOTIFY = ACT_CANCEL_DELETE + COND_NOTIFY FOLDER_TYPE_ANNOTATION = '/vendor/kolab/folder-type' MESSAGE_PROCESSED = 1 MESSAGE_FORWARD = 2 policy_name_map = { # policy values applying to all object types 'ALL_MANUAL': ACT_MANUAL + COND_TYPE_ALL, 'ALL_ACCEPT': ACT_ACCEPT + COND_TYPE_ALL, 'ALL_REJECT': ACT_REJECT + COND_TYPE_ALL, 'ALL_DELEGATE': ACT_DELEGATE + COND_TYPE_ALL, # not implemented 'ALL_UPDATE': ACT_UPDATE + COND_TYPE_ALL, 'ALL_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_ALL, 'ALL_SAVE_TO_FOLDER': ACT_SAVE_TO_FOLDER + COND_TYPE_ALL, 'ALL_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_ALL, 'ALL_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_ALL, 'ALL_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_ALL, # event related policy values 'EVENT_MANUAL': ACT_MANUAL + COND_TYPE_EVENT, 'EVENT_ACCEPT': ACT_ACCEPT + COND_TYPE_EVENT, 'EVENT_TENTATIVE': ACT_TENTATIVE + COND_TYPE_EVENT, 'EVENT_REJECT': ACT_REJECT + COND_TYPE_EVENT, 'EVENT_DELEGATE': ACT_DELEGATE + COND_TYPE_EVENT, # not implemented 'EVENT_UPDATE': ACT_UPDATE + COND_TYPE_EVENT, 'EVENT_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_EVENT, 'EVENT_ACCEPT_IF_NO_CONFLICT': ACT_ACCEPT + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'EVENT_TENTATIVE_IF_NO_CONFLICT': ACT_ACCEPT + COND_TENTATIVE + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'EVENT_DELEGATE_IF_CONFLICT': ACT_DELEGATE + COND_IF_CONFLICT + COND_TYPE_EVENT, 'EVENT_REJECT_IF_CONFLICT': ACT_REJECT + COND_IF_CONFLICT + COND_TYPE_EVENT, 'EVENT_SAVE_TO_FOLDER': ACT_SAVE_TO_FOLDER + COND_TYPE_EVENT, 'EVENT_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_EVENT, 'EVENT_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_EVENT, 'EVENT_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_EVENT, # task related policy values 'TASK_MANUAL': ACT_MANUAL + COND_TYPE_TASK, 'TASK_ACCEPT': ACT_ACCEPT + COND_TYPE_TASK, 'TASK_REJECT': ACT_REJECT + COND_TYPE_TASK, 'TASK_DELEGATE': ACT_DELEGATE + COND_TYPE_TASK, # not implemented 'TASK_UPDATE': ACT_UPDATE + COND_TYPE_TASK, 'TASK_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_TASK, 'TASK_SAVE_TO_FOLDER': ACT_SAVE_TO_FOLDER + COND_TYPE_TASK, 'TASK_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_TASK, 'TASK_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_TASK, 'TASK_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_TASK, # legacy values 'ACT_MANUAL': ACT_MANUAL + COND_TYPE_ALL, 'ACT_ACCEPT': ACT_ACCEPT + COND_TYPE_ALL, 'ACT_ACCEPT_IF_NO_CONFLICT': ACT_ACCEPT + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'ACT_TENTATIVE': ACT_TENTATIVE + COND_TYPE_EVENT, 'ACT_TENTATIVE_IF_NO_CONFLICT': ACT_ACCEPT + COND_TENTATIVE + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'ACT_DELEGATE': ACT_DELEGATE + COND_TYPE_ALL, 'ACT_DELEGATE_IF_CONFLICT': ACT_DELEGATE + COND_IF_CONFLICT + COND_TYPE_EVENT, 'ACT_REJECT': ACT_REJECT + COND_TYPE_ALL, 'ACT_REJECT_IF_CONFLICT': ACT_REJECT + COND_IF_CONFLICT + COND_TYPE_EVENT, 'ACT_UPDATE': ACT_UPDATE + COND_TYPE_ALL, 'ACT_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_ALL, 'ACT_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_ALL, 'ACT_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_ALL, 'ACT_SAVE_TO_CALENDAR': ACT_SAVE_TO_FOLDER + COND_TYPE_EVENT, 'ACT_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_EVENT, } policy_value_map = dict([(v &~ COND_TYPE_ALL, k) for (k, v) in policy_name_map.items()]) object_type_conditons = { 'event': COND_TYPE_EVENT, 'task': COND_TYPE_TASK } log = pykolab.getLogger('pykolab.wallace/invitationpolicy') extra_log_params = {'qid': '-'} log = pykolab.logger.LoggerAdapter(log, extra_log_params) conf = pykolab.getConf() mybasepath = '/var/spool/pykolab/wallace/invitationpolicy/' auth = None imap = None write_locks = [] def __init__(): modules.register('invitationpolicy', execute, description=description()) def accept(filepath): new_filepath = os.path.join( mybasepath, 'ACCEPT', os.path.basename(filepath) ) cleanup() os.rename(filepath, new_filepath) filepath = new_filepath exec('modules.cb_action_ACCEPT(%r, %r)' % ('invitationpolicy',filepath)) def reject(filepath): new_filepath = os.path.join( mybasepath, 'REJECT', os.path.basename(filepath) ) os.rename(filepath, new_filepath) filepath = new_filepath exec('modules.cb_action_REJECT(%r, %r)' % ('invitationpolicy',filepath)) def description(): return """Invitation policy execution module.""" def cleanup(): global auth, imap, write_locks, extra_log_params log.debug("cleanup(): %r, %r" % (auth, imap), level=8) extra_log_params['qid'] = '-' auth.disconnect() del auth # Disconnect IMAP or we lock the mailbox almost constantly imap.disconnect() del imap # remove remaining write locks for key in write_locks: remove_write_lock(key, False) def execute(*args, **kw): global auth, imap, extra_log_params filepath = args[0] extra_log_params['qid'] = os.path.basename(filepath) # (re)set language to default pykolab.translate.setUserLanguage(conf.get('kolab','default_locale')) if not os.path.isdir(mybasepath): os.makedirs(mybasepath) for stage in ['incoming', 'ACCEPT', 'REJECT', 'HOLD', 'DEFER', 'locks']: if not os.path.isdir(os.path.join(mybasepath, stage)): os.makedirs(os.path.join(mybasepath, stage)) log.debug(_("Invitation policy called for %r, %r") % (args, kw), level=8) auth = Auth() imap = IMAP() # ignore calls on lock files if '/locks/' in filepath or 'stage' in kw and kw['stage'] == 'locks': return False log.debug("Invitation policy executing for %r, %r" % (filepath, '/locks/' in filepath), level=8) if 'stage' in kw: log.debug(_("Issuing callback after processing to stage %s") % (kw['stage']), level=8) log.debug(_("Testing cb_action_%s()") % (kw['stage']), level=8) if hasattr(modules, 'cb_action_%s' % (kw['stage'])): log.debug(_("Attempting to execute cb_action_%s()") % (kw['stage']), level=8) exec( 'modules.cb_action_%s(%r, %r)' % ( kw['stage'], 'invitationpolicy', filepath ) ) return filepath else: # Move to incoming new_filepath = os.path.join( mybasepath, 'incoming', os.path.basename(filepath) ) if not filepath == new_filepath: log.debug("Renaming %r to %r" % (filepath, new_filepath)) os.rename(filepath, new_filepath) filepath = new_filepath # parse full message message = Parser().parse(open(filepath, 'r')) # invalid message, skip if not message.get('X-Kolab-To'): return filepath recipients = [address for displayname,address in getaddresses(message.get_all('X-Kolab-To'))] sender_email = [address for displayname,address in getaddresses(message.get_all('X-Kolab-From'))][0] any_itips = False recipient_email = None recipient_emails = [] recipient_user_dn = None # An iTip message may contain multiple events. Later on, test if the message # is an iTip message by checking the length of this list. try: itip_events = objects_from_message(message, ['VEVENT','VTODO'], ['REQUEST', 'REPLY', 'CANCEL']) except Exception as errmsg: log.error(_("Failed to parse iTip objects from message: %r" % (errmsg))) itip_events = [] if not len(itip_events) > 0: log.info(_("Message is not an iTip message or does not contain any (valid) iTip objects.")) else: any_itips = True log.debug(_("iTip objects attached to this message contain the following information: %r") % (itip_events), level=8) # See if any iTip actually allocates a user. if any_itips and len([x['uid'] for x in itip_events if 'attendees' in x or 'organizer' in x]) > 0: auth.connect() # we're looking at the first itip object itip_event = itip_events[0] for recipient in recipients: recipient_user_dn = user_dn_from_email_address(recipient) if recipient_user_dn: receiving_user = auth.get_entry_attributes(None, recipient_user_dn, ['*']) recipient_emails = auth.extract_recipient_addresses(receiving_user) recipient_email = recipient # extend with addresses from delegators # (only do this lookup for REPLY messages) receiving_user['_delegated_mailboxes'] = [] if itip_event['method'] == 'REPLY': for _delegator in auth.list_delegators(recipient_user_dn): if not _delegator['_mailbox_basename'] == None: receiving_user['_delegated_mailboxes'].append( _delegator['_mailbox_basename'].split('@')[0] ) log.debug(_("Recipient emails for %s: %r") % (recipient_user_dn, recipient_emails), level=8) break if not any_itips: log.debug(_("No itips, no users, pass along %r") % (filepath), level=5) return filepath elif recipient_email is None: log.debug(_("iTips, but no users, pass along %r") % (filepath), level=5) return filepath # for replies, the organizer is the recipient if itip_event['method'] == 'REPLY': # Outlook can send iTip replies without an organizer property if 'organizer' in itip_event: organizer_mailto = str(itip_event['organizer']).split(':')[-1] user_attendees = [organizer_mailto] if organizer_mailto in recipient_emails else [] else: user_attendees = [recipient_email] else: # Limit the attendees to the one that is actually invited with the current message. attendees = [str(a).split(':')[-1] for a in (itip_event['attendees'] if 'attendees' in itip_event else [])] user_attendees = [a for a in attendees if a in recipient_emails] if 'organizer' in itip_event: sender_email = itip_event['xml'].get_organizer().email() # abort if no attendee matches the envelope recipient if len(user_attendees) == 0: log.info(_("No user attendee matching envelope recipient %s, skip message") % (recipient_email)) return filepath log.debug(_("Receiving user: %r") % (receiving_user), level=8) # set recipient_email to the matching attendee mailto: address recipient_email = user_attendees[0] # change gettext language to the preferredlanguage setting of the receiving user if 'preferredlanguage' in receiving_user: pykolab.translate.setUserLanguage(receiving_user['preferredlanguage']) # find user's kolabInvitationPolicy settings and the matching policy values type_condition = object_type_conditons.get(itip_event['type'], COND_TYPE_ALL) policies = get_matching_invitation_policies(receiving_user, sender_email, type_condition) # select a processing function according to the iTip request method method_processing_map = { 'REQUEST': process_itip_request, 'REPLY': process_itip_reply, 'CANCEL': process_itip_cancel } done = None if itip_event['method'] in method_processing_map: processor_func = method_processing_map[itip_event['method']] # connect as cyrus-admin imap.connect() for policy in policies: log.debug(_("Apply invitation policy %r for sender %r") % (policy_value_map[policy], sender_email), level=8) done = processor_func(itip_event, policy, recipient_email, sender_email, receiving_user) # matching policy found if done is not None: break # remove possible write lock from this iteration remove_write_lock(get_lock_key(receiving_user, itip_event['uid'])) else: log.debug(_("Ignoring '%s' iTip method") % (itip_event['method']), level=8) # message has been processed by the module, remove it if done == MESSAGE_PROCESSED: log.debug(_("iTip message %r consumed by the invitationpolicy module") % (message.get('Message-ID')), level=5) os.unlink(filepath) cleanup() return None # accept message into the destination inbox accept(filepath) def process_itip_request(itip_event, policy, recipient_email, sender_email, receiving_user): """ Process an iTip REQUEST message according to the given policy """ # if invitation policy is set to MANUAL, pass message along if policy & ACT_MANUAL: log.info(_("Pass invitation for manual processing")) return MESSAGE_FORWARD try: receiving_attendee = itip_event['xml'].get_attendee_by_email(recipient_email) log.debug(_("Receiving attendee: %r") % (receiving_attendee.to_dict()), level=8) except Exception as errmsg: log.error("Could not find envelope attendee: %r" % (errmsg)) return MESSAGE_FORWARD # process request to participating attendees with RSVP=TRUE or PARTSTAT=NEEDS-ACTION is_task = itip_event['type'] == 'task' nonpart = receiving_attendee.get_role() == kolabformat.NonParticipant partstat = receiving_attendee.get_participant_status() save_object = not nonpart or not partstat == kolabformat.PartNeedsAction rsvp = receiving_attendee.get_rsvp() scheduling_required = rsvp or partstat == kolabformat.PartNeedsAction respond_with = receiving_attendee.get_participant_status(True) condition_fulfilled = True # find existing event in user's calendar (existing, master) = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True) # compare sequence number to determine a (re-)scheduling request if existing is not None: scheduling_required = itip_event['sequence'] > 0 and itip_event['sequence'] > existing.get_sequence() log.debug(_("Scheduling required: %r, for existing %s: %s") % (scheduling_required, existing.type, existing.get_uid()), level=8) save_object = True # if scheduling: check availability (skip that for tasks) if scheduling_required: if not is_task and policy & (COND_IF_AVAILABLE | COND_IF_CONFLICT): condition_fulfilled = check_availability(itip_event, receiving_user) if not is_task and policy & COND_IF_CONFLICT: condition_fulfilled = not condition_fulfilled log.debug(_("Precondition for object %r fulfilled: %r") % (itip_event['uid'], condition_fulfilled), level=5) if existing: respond_with = None if policy & ACT_ACCEPT and condition_fulfilled: respond_with = 'TENTATIVE' if policy & COND_TENTATIVE else 'ACCEPTED' elif policy & ACT_REJECT and condition_fulfilled: respond_with = 'DECLINED' # TODO: only save declined invitation when a certain config option is set? elif policy & ACT_DELEGATE and condition_fulfilled: # TODO: delegate (but to whom?) return None # auto-update changes if enabled for this user elif policy & ACT_UPDATE and existing: # compare sequence number to avoid outdated updates if not itip_event['sequence'] == existing.get_sequence(): log.info(_("The iTip request sequence (%r) doesn't match the referred object version (%r). Ignoring.") % ( itip_event['sequence'], existing.get_sequence() )) return None log.debug(_("Auto-updating %s %r on iTip REQUEST (no re-scheduling)") % (existing.type, existing.uid), level=8) save_object = True rsvp = False # retain task status and percent-complete properties from my old copy if is_task: itip_event['xml'].set_status(existing.get_status()) itip_event['xml'].set_percentcomplete(existing.get_percentcomplete()) if policy & COND_NOTIFY: sender = itip_event['xml'].get_organizer() comment = itip_event['xml'].get_comment() send_update_notification(itip_event['xml'], receiving_user, existing, False, sender, comment) # if RSVP, send an iTip REPLY if rsvp or scheduling_required: # set attendee's CN from LDAP record if yet missing if not receiving_attendee.get_name() and 'cn' in receiving_user: receiving_attendee.set_name(receiving_user['cn']) # send iTip reply if respond_with is not None and not respond_with == 'NEEDS-ACTION': receiving_attendee.set_participant_status(respond_with) send_reply(recipient_email, itip_event, invitation_response_text(itip_event['type']), subject=_('"%(summary)s" has been %(status)s')) elif policy & ACT_SAVE_TO_FOLDER: # copy the invitation into the user's default folder with PARTSTAT=NEEDS-ACTION itip_event['xml'].set_attendee_participant_status(receiving_attendee, respond_with or 'NEEDS-ACTION') save_object = True else: # policy doesn't match, pass on to next one return None if save_object: targetfolder = None # delete old version from IMAP if existing: targetfolder = existing._imap_folder delete_object(existing) elif master and hasattr(master, '_imap_folder'): targetfolder = master._imap_folder delete_object(master) if not nonpart or existing: # save new copy from iTip if store_object(itip_event['xml'], receiving_user, targetfolder, master): if policy & COND_FORWARD: log.debug(_("Forward invitation for notification"), level=5) return MESSAGE_FORWARD else: return MESSAGE_PROCESSED return None def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiving_user): """ Process an iTip REPLY message according to the given policy """ # if invitation policy is set to MANUAL, pass message along if policy & ACT_MANUAL: log.info(_("Pass reply for manual processing")) return MESSAGE_FORWARD # auto-update is enabled for this user if policy & ACT_UPDATE: try: sender_attendee = itip_event['xml'].get_attendee_by_email(sender_email) log.debug(_("Sender Attendee: %r") % (sender_attendee), level=8) except Exception as errmsg: log.error("Could not find envelope sender attendee: %r" % (errmsg)) return MESSAGE_FORWARD # find existing event in user's calendar # sets/checks lock to avoid concurrent wallace processes trying to update the same event simultaneously (existing, master) = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True) if existing: # compare sequence number to avoid outdated replies? if not itip_event['sequence'] == existing.get_sequence(): log.info(_("The iTip reply sequence (%r) doesn't match the referred object version (%r). Forwarding to Inbox.") % ( itip_event['sequence'], existing.get_sequence() )) remove_write_lock(existing._lock_key) return MESSAGE_FORWARD log.debug(_("Auto-updating %s %r on iTip REPLY") % (existing.type, existing.uid), level=8) updated_attendees = [] try: existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status(), rsvp=False) existing_attendee = existing.get_attendee(sender_email) updated_attendees.append(existing_attendee) except Exception as errmsg: log.error("Could not find corresponding attende in organizer's copy: %r" % (errmsg)) # append delegated-from attendee ? if len(sender_attendee.get_delegated_from()) > 0: existing.add_attendee(sender_attendee) updated_attendees.append(sender_attendee) else: # TODO: accept new participant if ACT_ACCEPT ? remove_write_lock(existing._lock_key) return MESSAGE_FORWARD # append delegated-to attendee if len(sender_attendee.get_delegated_to()) > 0: try: delegatee_email = sender_attendee.get_delegated_to(True)[0] sender_delegatee = itip_event['xml'].get_attendee_by_email(delegatee_email) existing_delegatee = existing.find_attendee(delegatee_email) if not existing_delegatee: existing.add_attendee(sender_delegatee) log.debug(_("Add delegatee: %r") % (sender_delegatee.to_dict()), level=8) else: existing_delegatee.copy_from(sender_delegatee) log.debug(_("Update existing delegatee: %r") % (existing_delegatee.to_dict()), level=8) updated_attendees.append(sender_delegatee) # copy all parameters from replying attendee (e.g. delegated-to, role, etc.) existing_attendee.copy_from(sender_attendee) existing.update_attendees([existing_attendee]) log.debug(_("Update delegator: %r") % (existing_attendee.to_dict()), level=8) except Exception as errmsg: log.error("Could not find delegated-to attendee: %r" % (errmsg)) # update the organizer's copy of the object if update_object(existing, receiving_user, master): if policy & COND_NOTIFY: send_update_notification(existing, receiving_user, existing, True, sender_attendee, itip_event['xml'].get_comment()) # update all other attendee's copies if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'): propagate_changes_to_attendees_accounts(existing, updated_attendees) return MESSAGE_PROCESSED else: log.error(_("The object referred by this reply was not found in the user's folders. Forwarding to Inbox.")) return MESSAGE_FORWARD return None def process_itip_cancel(itip_event, policy, recipient_email, sender_email, receiving_user): """ Process an iTip CANCEL message according to the given policy """ # if invitation policy is set to MANUAL, pass message along if policy & ACT_MANUAL: log.info(_("Pass cancellation for manual processing")) return MESSAGE_FORWARD # auto-update the local copy if policy & ACT_UPDATE or policy & ACT_CANCEL_DELETE: # find existing object in user's folders (existing, master) = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True) remove_object = policy & ACT_CANCEL_DELETE if existing: # on this-and-future cancel requests, set the recurrence until date on the master event if itip_event['recurrence-id'] and master and itip_event['xml'].get_thisandfuture(): rrule = master.get_recurrence() rrule.set_count(0) rrule.set_until(existing.get_start() + datetime.timedelta(days=-1)) master.set_recurrence(rrule) existing.set_recurrence_id(existing.get_recurrence_id(), True) remove_object = False # delete the local copy if remove_object: # remove exception and register an exdate to the main event if master: log.debug(_("Remove cancelled %s instance %s from %r") % (existing.type, itip_event['recurrence-id'], existing.uid), level=8) master.add_exception_date(existing.get_start()) master.del_exception(existing) success = update_object(master, receiving_user) # delete main event else: success = delete_object(existing) # update the local copy with STATUS=CANCELLED else: log.debug(_("Update cancelled %s %r with STATUS=CANCELLED") % (existing.type, existing.uid), level=8) existing.set_status('CANCELLED') existing.set_transparency(True) success = update_object(existing, receiving_user, master) if success: # send cancellation notification if policy & COND_NOTIFY: sender = itip_event['xml'].get_organizer() comment = itip_event['xml'].get_comment() send_cancel_notification(existing, receiving_user, remove_object, sender, comment) return MESSAGE_PROCESSED else: log.error(_("The object referred by this cancel request was not found in the user's folders. Forwarding to Inbox.")) return MESSAGE_FORWARD return None def user_dn_from_email_address(email_address): """ Resolves the given email address to a Kolab user entity """ global auth if not auth: auth = Auth() auth.connect() # return cached value if email_address in user_dn_from_email_address.cache: return user_dn_from_email_address.cache[email_address] local_domains = auth.list_domains() if local_domains is not None: local_domains = list(set(local_domains.keys())) if not email_address.split('@')[1] in local_domains: user_dn_from_email_address.cache[email_address] = None return None log.debug(_("Checking if email address %r belongs to a local user") % (email_address), level=8) user_dn = auth.find_user_dn(email_address, True) if isinstance(user_dn, basestring): log.debug(_("User DN: %r") % (user_dn), level=8) else: log.debug(_("No user record(s) found for %r") % (email_address), level=8) # remember this lookup user_dn_from_email_address.cache[email_address] = user_dn return user_dn user_dn_from_email_address.cache = {} def get_matching_invitation_policies(receiving_user, sender_email, type_condition=COND_TYPE_ALL): # get user's kolabInvitationPolicy settings policies = receiving_user['kolabinvitationpolicy'] if 'kolabinvitationpolicy' in receiving_user else [] if policies and not isinstance(policies, list): policies = [policies] if len(policies) == 0: policies = conf.get_list('wallace', 'kolab_invitation_policy') # match policies agains the given sender_email matches = [] for p in policies: if ':' in p: (value, domain) = p.split(':', 1) else: value = p domain = '' if domain == '' or domain == '*' or str(sender_email).endswith(domain): value = value.upper() if value in policy_name_map: val = policy_name_map[value] # append if type condition matches if val & type_condition: matches.append(val &~ COND_TYPE_ALL) # add manual as default action if len(matches) == 0: matches.append(ACT_MANUAL) return matches def imap_proxy_auth(user_rec): """ Perform IMAP login using proxy authentication with admin credentials """ global imap mail_attribute = conf.get('cyrus-sasl', 'result_attribute') if mail_attribute is None: mail_attribute = 'mail' mail_attribute = mail_attribute.lower() if mail_attribute not in user_rec: log.error(_("User record doesn't have the mailbox attribute %r set" % (mail_attribute))) return False # do IMAP prox auth with the given user backend = conf.get('kolab', 'imap_backend') admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') try: imap.disconnect() imap.connect(login=False) imap.login_plain(admin_login, admin_password, user_rec[mail_attribute]) except Exception as errmsg: log.error(_("IMAP proxy authentication failed: %r") % (errmsg)) return False return True def list_user_folders(user_rec, _type): """ Get a list of the given user's private calendar/tasks folders """ global imap # return cached list if '_imap_folders' in user_rec: return user_rec['_imap_folders'] result = [] if not imap_proxy_auth(user_rec): return result folders = imap.get_metadata('*') log.debug( _("List %r folders for user %r: %r") % ( _type, user_rec['mail'], folders ), level=8 ) (ns_personal, ns_other, ns_shared) = imap.namespaces() _folders = {} # Filter the folders by type relevance for folder, metadata in folders.items(): key = '/shared' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): _folders[folder] = metadata key = '/private' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): _folders[folder] = metadata for folder, metadata in _folders.items(): folder_delegated = False # Exclude shared and other user's namespace # # First, test if this is another users folder if ns_other is not None and folder.startswith(ns_other): # If we have no delegated mailboxes, we can skip this entirely if '_delegated_mailboxes' not in user_rec: continue for _m in user_rec['_delegated_mailboxes']: if folder.startswith(ns_other + _m + '/'): folder_delegated = True if not folder_delegated: continue # TODO: list shared folders the user has write privileges ? if ns_shared is not None: if len([_ns for _ns in ns_shared if folder.startswith(_ns)]) > 0: continue key = '/shared' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): result.append(folder) key = '/private' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): result.append(folder) # store default folder in user record if metadata[key].endswith('.default'): user_rec['_default_folder'] = folder continue # store private and confidential folders in user record if metadata[key].endswith('.confidential'): if '_confidential_folder' not in user_rec: user_rec['_confidential_folder'] = folder continue if metadata[key].endswith('.private'): if '_private_folder' not in user_rec: user_rec['_private_folder'] = folder continue # cache with user record user_rec['_imap_folders'] = result return result def find_existing_object(uid, type, recurrence_id, user_rec, lock=False): """ Search user's private folders for the given object (by UID+type) """ global imap lock_key = None if lock: lock_key = get_lock_key(user_rec, uid) set_write_lock(lock_key) event = None master = None for folder in list_user_folders(user_rec, type): log.debug(_("Searching folder %r for %s %r") % (folder, type, uid), level=8) imap.imap.m.select(imap.folder_utf7(folder)) res, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid)) for num in reversed(data[0].split()): res, data = imap.imap.m.fetch(num, '(UID RFC822)') try: msguid = re.search(r"\WUID (\d+)", data[0][0]).group(1) except Exception: log.error(_("No UID found in IMAP response: %r") % (data[0][0])) continue try: if type == 'task': event = todo_from_message(message_from_string(data[0][1])) else: event = event_from_message(message_from_string(data[0][1])) # find instance in a recurring series if recurrence_id and (event.is_recurring() or event.has_exceptions() or event.get_recurrence_id()): master = event event = master.get_instance(recurrence_id) setattr(master, '_imap_folder', folder) setattr(master, '_msguid', msguid) # return master, even if instance is not found if not event and master.uid == uid: return (event, master) if event is not None: setattr(event, '_imap_folder', folder) setattr(event, '_lock_key', lock_key) setattr(event, '_msguid', msguid) except Exception: log.error(_("Failed to parse %s from message %s/%s: %s") % (type, folder, num, traceback.format_exc())) event = None master = None continue if event and event.uid == uid: return (event, master) if lock_key is not None: remove_write_lock(lock_key) return (event, master) def check_availability(itip_event, receiving_user): """ For the receiving user, determine if the event in question is in conflict. """ start = time.time() num_messages = 0 conflict = False # return previously detected conflict if '_conflicts' in itip_event: return not itip_event['_conflicts'] for folder in list_user_folders(receiving_user, 'event'): log.debug(_("Listing events from folder %r") % (folder), level=8) imap.imap.m.select(imap.folder_utf7(folder)) res, data = imap.imap.m.search(None, '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")') num_messages += len(data[0].split()) for num in reversed(data[0].split()): event = None res, data = imap.imap.m.fetch(num, '(RFC822)') try: event = event_from_message(message_from_string(data[0][1])) except Exception as errmsg: log.error(_("Failed to parse event from message %s/%s: %r") % (folder, num, errmsg)) continue if event and event.uid: conflict = check_event_conflict(event, itip_event) if conflict: log.info(_("Existing event %r conflicts with invitation %r") % (event.uid, itip_event['uid'])) break if conflict: break end = time.time() log.debug(_("start: %r, end: %r, total: %r, messages: %d") % (start, end, (end-start), num_messages), level=8) # remember the result of this check for further iterations itip_event['_conflicts'] = conflict return not conflict def set_write_lock(key, wait=True): """ Set a write-lock for the given key and wait if such a lock already exists """ if not os.path.isdir(mybasepath): os.makedirs(mybasepath) if not os.path.isdir(os.path.join(mybasepath, 'locks')): os.makedirs(os.path.join(mybasepath, 'locks')) filename = os.path.join(mybasepath, 'locks', key + '.lock') locktime = 0 if os.path.isfile(filename): locktime = os.path.getmtime(filename) # wait if file lock is in place while time.time() < locktime + 300: if not wait: return False log.debug(_("%r is locked, waiting...") % (key), level=8) time.sleep(0.5) locktime = os.path.getmtime(filename) if os.path.isfile(filename) else 0 # touch the file if os.path.isfile(filename): os.utime(filename, None) else: open(filename, 'w').close() # register active lock write_locks.append(key) return True def remove_write_lock(key, update=True): """ Remove the lock file for the given key """ global write_locks if key is not None: file = os.path.join(mybasepath, 'locks', key + '.lock') if os.path.isfile(file): os.remove(file) if update: write_locks = [k for k in write_locks if not k == key] def get_lock_key(user, uid): return hashlib.md5("%s/%s" % (user['mail'], uid)).hexdigest() def update_object(object, user_rec, master=None): """ Update the given object in IMAP (i.e. delete + append) """ success = False saveobj = object # updating a single instance only: use master event if object.get_recurrence_id() and master: saveobj = master if hasattr(saveobj, '_imap_folder'): if delete_object(saveobj): saveobj.set_lastmodified() # update last-modified timestamp success = store_object(object, user_rec, saveobj._imap_folder, master) # remove write lock for this event if hasattr(saveobj, '_lock_key') and saveobj._lock_key is not None: remove_write_lock(saveobj._lock_key) return success def store_object(object, user_rec, targetfolder=None, master=None): """ Append the given object to the user's default calendar/tasklist """ # find calendar folder to save object to if not specified if targetfolder is None: targetfolders = list_user_folders(user_rec, object.type) oc = object.get_classification() # use *.confidential/private folder for confidential/private invitations if oc == kolabformat.ClassConfidential and '_confidential_folder' in user_rec: targetfolder = user_rec['_confidential_folder'] elif oc == kolabformat.ClassPrivate and '_private_folder' in user_rec: targetfolder = user_rec['_private_folder'] # use *.default folder if exists elif '_default_folder' in user_rec: targetfolder = user_rec['_default_folder'] # fallback to any existing folder of specified type elif targetfolders is not None and len(targetfolders) > 0: targetfolder = targetfolders[0] if targetfolder is None: log.error(_("Failed to save %s: no target folder found for user %r") % (object.type, user_rec['mail'])) return False saveobj = object # updating a single instance only: add exception to master event if object.get_recurrence_id() and master: object.set_lastmodified() # update last-modified timestamp master.add_exception(object) saveobj = master log.debug(_("Save %s %r to user folder %r") % (saveobj.type, saveobj.uid, targetfolder), level=8) try: imap.imap.m.select(imap.folder_utf7(targetfolder)) result = imap.imap.m.append( imap.folder_utf7(targetfolder), None, None, saveobj.to_message(creator="Kolab Server ").as_string() ) return result except Exception as errmsg: log.error(_("Failed to save %s to user folder at %r: %r") % ( saveobj.type, targetfolder, errmsg )) return False def delete_object(existing): """ Removes the IMAP object with the given UID from a user's folder """ targetfolder = existing._imap_folder msguid = existing._msguid if hasattr(existing, '_msguid') else None try: imap.imap.m.select(imap.folder_utf7(targetfolder)) # delete by IMAP UID if msguid is not None: log.debug(_("Delete %s %r in %r by UID: %r") % ( existing.type, existing.uid, targetfolder, msguid ), level=8) imap.imap.m.uid('store', msguid, '+FLAGS', '(\\Deleted)') else: res, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % existing.uid) log.debug(_("Delete %s %r in %r: %r") % ( existing.type, existing.uid, targetfolder, data ), level=8) for num in data[0].split(): imap.imap.m.store(num, '+FLAGS', '(\\Deleted)') imap.imap.m.expunge() return True except Exception as errmsg: log.error(_("Failed to delete %s from folder %r: %r") % ( existing.type, targetfolder, errmsg )) return False def send_update_notification(object, receiving_user, old=None, reply=True, sender=None, comment=None): """ Send a (consolidated) notification about the current participant status to organizer """ global auth from email.MIMEText import MIMEText from email.Utils import formatdate from email.header import Header from email import charset # encode unicode strings with quoted-printable charset.add_charset('utf-8', charset.SHORTEST, charset.QP) organizer = object.get_organizer() orgemail = organizer.email() orgname = organizer.name() itip_comment = None if comment is not None: comment = comment.strip() if sender is not None and not comment == '': itip_comment = _("%s commented: %s") % (_attendee_name(sender), comment) if reply: log.debug(_("Compose participation status summary for %s %r to user %r") % ( object.type, object.uid, receiving_user['mail'] ), level=8) auto_replies_expected = 0 auto_replies_received = 0 is_manual_reply = True partstats = {'ACCEPTED': [], 'TENTATIVE': [], 'DECLINED': [], 'DELEGATED': [], 'IN-PROCESS': [], 'COMPLETED': [], 'PENDING': []} for attendee in object.get_attendees(): parstat = attendee.get_participant_status(True) if parstat in partstats: partstats[parstat].append(attendee.get_displayname()) else: partstats['PENDING'].append(attendee.get_displayname()) # look-up kolabinvitationpolicy for this attendee if attendee.get_cutype() == kolabformat.CutypeResource: resource_dns = auth.find_resource(attendee.get_email()) if isinstance(resource_dns, list): attendee_dn = resource_dns[0] if len(resource_dns) > 0 else None else: attendee_dn = resource_dns else: attendee_dn = user_dn_from_email_address(attendee.get_email()) if attendee_dn: attendee_rec = auth.get_entry_attributes(None, attendee_dn, ['kolabinvitationpolicy']) if is_auto_reply(attendee_rec, orgemail, object.type): auto_replies_expected += 1 if not parstat == 'NEEDS-ACTION': auto_replies_received += 1 if sender is not None and sender.get_email() == attendee.get_email(): is_manual_reply = False # skip notification until we got replies from all automatically responding attendees if not is_manual_reply and auto_replies_received < auto_replies_expected: log.debug(_("Waiting for more automated replies (got %d of %d); skipping notification") % ( auto_replies_received, auto_replies_expected ), level=8) return # build notification message body roundup = '' if itip_comment is not None: roundup += "\n" + itip_comment for status,attendees in partstats.items(): if len(attendees) > 0: roundup += "\n" + participant_status_label(status) + ":\n\t" + "\n\t".join(attendees) + "\n" else: # build notification message body roundup = '' if itip_comment is not None: roundup += "\n" + itip_comment roundup += "\n" + _("Changes submitted by %s have been automatically applied.") % (orgname if orgname else orgemail) # list properties changed from previous version if old: diff = xmlutils.compute_diff(old.to_dict(), object.to_dict()) if len(diff) > 1: roundup += "\n" for change in diff: if not change['property'] in ['created','lastmodified-date','sequence']: new_value = xmlutils.property_to_string(change['property'], change['new']) if change['new'] else _("(removed)") if new_value: roundup += "\n- %s: %s" % (xmlutils.property_label(change['property']), new_value) # compose different notification texts for events/tasks if object.type == 'task': message_text = _(""" The assignment for '%(summary)s' has been updated in your tasklist. %(roundup)s """) % { 'summary': object.get_summary(), 'roundup': roundup } else: message_text = _(""" The event '%(summary)s' at %(start)s has been updated in your calendar. %(roundup)s """) % { 'summary': object.get_summary(), 'start': xmlutils.property_to_string('start', object.get_start()), 'roundup': roundup } if object.get_recurrence_id(): message_text += _("NOTE: This update only refers to this single occurrence!") + "\n" message_text += "\n" + _("*** This is an automated message. Please do not reply. ***") # compose mime message msg = MIMEText(utils.stripped_message(message_text), _charset='utf-8') msg['To'] = receiving_user['mail'] msg['Date'] = formatdate(localtime=True) msg['Subject'] = utils.str2unicode(_('"%s" has been updated') % (object.get_summary())) msg['From'] = Header(utils.str2unicode('%s' % orgname) if orgname else '') msg['From'].append("<%s>" % orgemail) seed = random.randint(0, 6) alarm_after = (seed * 10) + 60 log.debug(_("Set alarm to %s seconds") % (alarm_after), level=8) signal.alarm(alarm_after) result = modules._sendmail(orgemail, receiving_user['mail'], msg.as_string()) log.debug(_("Sent update notification to %r: %r") % (receiving_user['mail'], result), level=8) signal.alarm(0) def send_cancel_notification(object, receiving_user, deleted=False, sender=None, comment=None): """ Send a notification about event/task cancellation """ from email.MIMEText import MIMEText from email.Utils import formatdate from email.header import Header from email import charset # encode unicode strings with quoted-printable charset.add_charset('utf-8', charset.SHORTEST, charset.QP) log.debug(_("Send cancellation notification for %s %r to user %r") % ( object.type, object.uid, receiving_user['mail'] ), level=8) organizer = object.get_organizer() orgemail = organizer.email() orgname = organizer.name() # compose different notification texts for events/tasks if object.type == 'task': message_text = _("The assignment for '%(summary)s' has been cancelled by %(organizer)s.") % { 'summary': object.get_summary(), 'organizer': orgname if orgname else orgemail } if deleted: message_text += " " + _("The copy in your tasklist has been removed accordingly.") else: message_text += " " + _("The copy in your tasklist has been marked as cancelled accordingly.") else: message_text = _("The event '%(summary)s' at %(start)s has been cancelled by %(organizer)s.") % { 'summary': object.get_summary(), 'start': xmlutils.property_to_string('start', object.get_start()), 'organizer': orgname if orgname else orgemail } if deleted: message_text += " " + _("The copy in your calendar has been removed accordingly.") else: message_text += " " + _("The copy in your calendar has been marked as cancelled accordingly.") if comment is not None: comment = comment.strip() if sender is not None and not comment == '': message_text += "\n" + _("%s commented: %s") % (_attendee_name(sender), comment) if object.get_recurrence_id(): message_text += "\n" + _("NOTE: This cancellation only refers to this single occurrence!") message_text += "\n\n" + _("*** This is an automated message. Please do not reply. ***") # compose mime message msg = MIMEText(utils.stripped_message(message_text), _charset='utf-8') msg['To'] = receiving_user['mail'] msg['Date'] = formatdate(localtime=True) msg['Subject'] = utils.str2unicode(_('"%s" has been cancelled') % (object.get_summary())) msg['From'] = Header(utils.str2unicode('%s' % orgname) if orgname else '') msg['From'].append("<%s>" % orgemail) seed = random.randint(0, 6) alarm_after = (seed * 10) + 60 log.debug(_("Set alarm to %s seconds") % (alarm_after), level=8) signal.alarm(alarm_after) result = modules._sendmail(orgemail, receiving_user['mail'], msg.as_string()) log.debug(_("Sent cancel notification to %r: %r") % (receiving_user['mail'], result), level=8) signal.alarm(0) def is_auto_reply(user, sender_email, type): accept_available = False accept_conflicts = False for policy in get_matching_invitation_policies(user, sender_email, object_type_conditons.get(type, COND_TYPE_EVENT)): if policy & (ACT_ACCEPT | ACT_REJECT | ACT_DELEGATE): if check_policy_condition(policy, True): accept_available = True if check_policy_condition(policy, False): accept_conflicts = True # we have both cases covered by a policy if accept_available and accept_conflicts: return True # manual action reached if policy & (ACT_MANUAL | ACT_SAVE_TO_FOLDER): return False return False def check_policy_condition(policy, available): condition_fulfilled = True if policy & (COND_IF_AVAILABLE | COND_IF_CONFLICT): condition_fulfilled = available if policy & COND_IF_CONFLICT: condition_fulfilled = not condition_fulfilled return condition_fulfilled def propagate_changes_to_attendees_accounts(object, updated_attendees=None): """ Find and update copies of this object in all attendee's personal folders """ recurrence_id = object.get_recurrence_id() for attendee in object.get_attendees(): attendee_user_dn = user_dn_from_email_address(attendee.get_email()) if attendee_user_dn: attendee_user = auth.get_entry_attributes(None, attendee_user_dn, ['*']) (attendee_object, master_object) = find_existing_object(object.uid, object.type, recurrence_id, attendee_user, True) # does IMAP authenticate if attendee_object: # find attendee's entry by one of its email addresses attendee_emails = auth.extract_recipient_addresses(attendee_user) for attendee_email in attendee_emails: try: attendee_entry = attendee_object.get_attendee_by_email(attendee_email) except: attendee_entry = None if attendee_entry: break # copy all attendees from master object (covers additions and removals) new_attendees = [] for a in object.get_attendees(): # keep my own entry intact if attendee_entry is not None and attendee_entry.get_email() == a.get_email(): new_attendees.append(attendee_entry) else: new_attendees.append(a) attendee_object.set_attendees(new_attendees) if updated_attendees and not recurrence_id: log.debug("Update Attendees %r for %s" % ([a.get_email()+':'+a.get_participant_status(True) for a in updated_attendees], attendee_user['mail']), level=8) attendee_object.update_attendees(updated_attendees, False) success = update_object(attendee_object, attendee_user, master_object) log.debug(_("Updated %s's copy of %r: %r") % (attendee_user['mail'], object.uid, success), level=8) else: log.debug(_("Attendee %s's copy of %r not found") % (attendee_user['mail'], object.uid), level=8) else: log.debug(_("Attendee %r not found in LDAP") % (attendee.get_email()), level=8) def invitation_response_text(type): footer = "\n\n" + _("*** This is an automated message. Please do not reply. ***") if type == 'task': return _("%(name)s has %(status)s your assignment for %(summary)s.") + footer else: return _("%(name)s has %(status)s your invitation for %(summary)s.") + footer def _attendee_name(attendee): # attendee here can be Attendee or ContactReference try: name = attendee.get_name() except Exception: name = attendee.name() if name == '': try: name = attendee.get_email() except Exception: name = attendee.email() return name diff --git a/wallace/module_optout.py b/wallace/module_optout.py index c710180..6e44378 100644 --- a/wallace/module_optout.py +++ b/wallace/module_optout.py @@ -1,192 +1,195 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import json import os import random import tempfile import time -from urlparse import urlparse +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse import urllib from email import message_from_file from email.utils import formataddr from email.utils import getaddresses import modules import pykolab from pykolab.translate import _ log = pykolab.getLogger('pykolab.wallace/optout') conf = pykolab.getConf() mybasepath = '/var/spool/pykolab/wallace/optout/' def __init__(): modules.register('optout', execute, description=description()) def description(): return """Consult the opt-out service.""" def execute(*args, **kw): if not os.path.isdir(mybasepath): os.makedirs(mybasepath) for stage in ['incoming', 'ACCEPT', 'REJECT', 'HOLD', 'DEFER' ]: if not os.path.isdir(os.path.join(mybasepath, stage)): os.makedirs(os.path.join(mybasepath, stage)) # TODO: Test for correct call. filepath = args[0] if 'stage' in kw: log.debug(_("Issuing callback after processing to stage %s") % (kw['stage']), level=8) log.debug(_("Testing cb_action_%s()") % (kw['stage']), level=8) if hasattr(modules, 'cb_action_%s' % (kw['stage'])): log.debug(_("Attempting to execute cb_action_%s()") % (kw['stage']), level=8) exec('modules.cb_action_%s(%r, %r)' % (kw['stage'],'optout',filepath)) return #modules.next_module('optout') log.debug(_("Consulting opt-out service for %r, %r") % (args, kw), level=8) message = message_from_file(open(filepath, 'r')) envelope_sender = getaddresses(message.get_all('From', [])) recipients = { "To": getaddresses(message.get_all('To', [])), "Cc": getaddresses(message.get_all('Cc', [])) # TODO: Are those all recipient addresses? } # optout answers are ACCEPT, REJECT, HOLD or DEFER answers = [ 'ACCEPT', 'REJECT', 'HOLD', 'DEFER' ] # Initialize our results placeholders. _recipients = {} for answer in answers: _recipients[answer] = { "To": [], "Cc": [] } for recipient_type in recipients: for recipient in recipients[recipient_type]: log.debug( _("Running opt-out consult from envelope sender '%s " + \ "<%s>' to recipient %s <%s>") % ( envelope_sender[0][0], envelope_sender[0][1], recipient[0], recipient[1] ), level=8 ) optout_answer = request( { 'unique-message-id': 'bogus', 'envelope_sender': envelope_sender[0][1], 'recipient': recipient[1] } ) _recipients[optout_answer][recipient_type].append(recipient) #print _recipients ## ## TODO ## ## If one of them all is DEFER, DEFER the entire message and discard the ## other results. ## for answer in answers: # Create the directory for the answer if not os.path.isdir(os.path.join(mybasepath, answer)): os.makedirs(os.path.join(mybasepath, answer)) # Consider using a new mktemp()-like call new_filepath = os.path.join(mybasepath, answer, os.path.basename(filepath)) # Write out a message file representing the new contents for the message # use formataddr(recipient) _message = message_from_file(open(filepath, 'r')) use_this = False for recipient_type in _recipients[answer]: _message.__delitem__(recipient_type) if not len(_recipients[answer][recipient_type]) == 0: _message.__setitem__( recipient_type, ',\n '.join( [formataddr(x) for x in _recipients[answer][recipient_type]] ) ) use_this = True if use_this: # TODO: Do not set items with an empty list. (fp, filename) = tempfile.mkstemp(dir="/var/spool/pykolab/wallace/optout/%s" % (answer)) os.write(fp, _message.__str__()) os.close(fp) # Callback with new filename if hasattr(modules, 'cb_action_%s' % (answer)): log.debug(_("Attempting to execute cb_action_%s(%r, %r)") % (answer, 'optout', filename), level=8) exec('modules.cb_action_%s(%r, %r)' % (answer,'optout', filename)) os.unlink(filepath) #print "Moving filepath %s to new_filepath %s" % (filepath, new_filepath) #os.rename(filepath, new_filepath) #if hasattr(modules, 'cb_action_%s' % (optout_answer)): #log.debug(_("Attempting to execute cb_action_%s()") % (optout_answer), level=8) #exec('modules.cb_action_%s(%r, %r)' % (optout_answer,'optout', new_filepath)) #return def request(params=None): params = json.dumps(params) optout_url = conf.get('wallace_optout', 'optout_url') try: f = urllib.urlopen(optout_url, params) except Exception: log.error(_("Could not send request to optout_url %s") % (optout_url)) return "DEFER" response = f.read() try: response_data = json.loads(response) except ValueError: # Some data is not JSON print("Response data is not JSON") return response_data['result']