diff options
author | Guilhem Moulin <guilhem@fripost.org> | 2014-07-06 19:10:59 +0200 |
---|---|---|
committer | Guilhem Moulin <guilhem@fripost.org> | 2015-06-07 02:52:31 +0200 |
commit | f877db8c189fc0a0c43aa5df9303ad34cceb774e (patch) | |
tree | a472854d639de03d7338e2c00dd761a89a3fd382 /lib/modules | |
parent | a427a868d388513da7b5386ba36f1898d7048dd0 (diff) |
Move ansible modules to another directory.
Diffstat (limited to 'lib/modules')
-rw-r--r-- | lib/modules/mysql_user | 491 | ||||
-rw-r--r-- | lib/modules/openldap | 434 | ||||
-rw-r--r-- | lib/modules/postmap | 109 | ||||
-rw-r--r-- | lib/modules/postmulti | 103 |
4 files changed, 1137 insertions, 0 deletions
diff --git a/lib/modules/mysql_user b/lib/modules/mysql_user new file mode 100644 index 0000000..64e1f3d --- /dev/null +++ b/lib/modules/mysql_user @@ -0,0 +1,491 @@ +#!/usr/bin/python + +# (c) 2012, Mark Theunissen <mark.theunissen@gmail.com> +# Sponsored by Four Kitchens http://fourkitchens.com. +# +# This file is part of Ansible +# +# Ansible 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. +# +# Ansible 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 Ansible. If not, see <http://www.gnu.org/licenses/>. + +DOCUMENTATION = ''' +--- +module: mysql_user +short_description: Adds or removes a user from a MySQL database. +description: + - Adds or removes a user from a MySQL database. +version_added: "0.6" +options: + name: + description: + - name of the user (role) to add or remove + required: true + default: null + password: + description: + - set the user's password + required: false + default: null + host: + description: + - the 'host' part of the MySQL username + required: false + default: localhost + login_user: + description: + - The username used to authenticate with + required: false + default: null + login_password: + description: + - The password used to authenticate with + required: false + default: null + login_host: + description: + - Host running the database + required: false + default: localhost + login_port: + description: + - Port of the MySQL server + required: false + default: 3306 + version_added: '1.4' + login_unix_socket: + description: + - The path to a Unix domain socket for local connections + required: false + default: null + priv: + description: + - "MySQL privileges string in the format: C(db.table:priv1,priv2)" + required: false + default: null + append_privs: + description: + - Append the privileges defined by priv to the existing ones for this + user instead of overwriting existing ones. + required: false + choices: [ "yes", "no" ] + default: "no" + version_added: "1.4" + state: + description: + - Whether the user should exist. When C(absent), removes + the user. + required: false + default: present + choices: [ "present", "absent" ] + check_implicit_admin: + description: + - Check if mysql allows login as root/nopassword before trying supplied credentials. + required: false + default: false + version_added: "1.3" +notes: + - Requires the MySQLdb Python package on the remote host. For Ubuntu, this + is as easy as apt-get install python-mysqldb. + - Both C(login_password) and C(login_username) are required when you are + passing credentials. If none are present, the module will attempt to read + the credentials from C(~/.my.cnf), and finally fall back to using the MySQL + default login of 'root' with no password. + - "MySQL server installs with default login_user of 'root' and no password. To secure this user + as part of an idempotent playbook, you must create at least two tasks: the first must change the root user's password, + without providing any login_user/login_password details. The second must drop a ~/.my.cnf file containing + the new root credentials. Subsequent runs of the playbook will then succeed by reading the new credentials from + the file." + +requirements: [ "ConfigParser", "MySQLdb" ] +author: Mark Theunissen +''' + +EXAMPLES = """ +# Create database user with name 'bob' and password '12345' with all database privileges +- mysql_user: name=bob password=12345 priv=*.*:ALL state=present + +# Ensure no user named 'sally' exists, also passing in the auth credentials. +- mysql_user: login_user=root login_password=123456 name=sally state=absent + +# Example privileges string format +mydb.*:INSERT,UPDATE/anotherdb.*:SELECT/yetanotherdb.*:ALL + +# Example using login_unix_socket to connect to server +- mysql_user: name=root password=abc123 login_unix_socket=/var/run/mysqld/mysqld.sock + +# Example .my.cnf file for setting the root password +# Note: don't use quotes around the password, because the mysql_user module +# will include them in the password but the mysql client will not + +[client] +user=root +password=n<_665{vS43y +""" + +import ConfigParser +import getpass +import tempfile +try: + import MySQLdb +except ImportError: + mysqldb_found = False +else: + mysqldb_found = True + +# =========================================== +# MySQL module specific support methods. +# + +def user_exists(cursor, user, host): + cursor.execute("SELECT count(*) FROM user WHERE user = %s AND host = %s", (user,host)) + count = cursor.fetchone() + return count[0] > 0 + +def load_plugin(cursor, plugin): + cursor.execute("SELECT count(*) FROM plugin WHERE name = %s", plugin) + count = cursor.fetchone() + if count[0] == 0: + so = "%s.so" % plugin + cursor.execute("INSTALL PLUGIN %s SONAME %s", (plugin, so)) + +def user_add(cursor, user, host, password, new_priv, auth_plugin): + if password is None: + # Automatically loaded on first first use. + load_plugin(cursor, auth_plugin) + cursor.execute("CREATE USER %s@%s IDENTIFIED WITH %s", (user,host,auth_plugin)) + else: + cursor.execute("CREATE USER %s@%s IDENTIFIED BY %s", (user,host,password)) + if new_priv is not None: + for db_table, priv in new_priv.iteritems(): + privileges_grant(cursor, user,host,db_table,priv) + return True + +def user_mod(cursor, user, host, password, new_priv, append_privs, auth_plugin): + changed = False + grant_option = False + + # Handle plugin. + if auth_plugin is not None: + cursor.execute("SELECT plugin FROM user WHERE user = %s AND host = %s", (user,host)) + if cursor.fetchone()[0] != auth_plugin: + # Sadly there is no proper way to updade the authentication plugin: + # http://bugs.mysql.com/bug.php?id=67449 + cursor.execute( "UPDATE user SET plugin = %s, password = '' WHERE user = %s AND host = %s" + , (auth_plugin,user,host)) + cursor.execute("FLUSH PRIVILEGES") + changed = True + + # Handle passwords. + if password is not None: + cursor.execute("SELECT password FROM user WHERE user = %s AND host = %s", (user,host)) + current_pass_hash = cursor.fetchone() + cursor.execute("SELECT PASSWORD(%s)", (password,)) + new_pass_hash = cursor.fetchone() + if current_pass_hash[0] != new_pass_hash[0]: + cursor.execute("SET PASSWORD FOR %s@%s = PASSWORD(%s)", (user,host,password)) + changed = True + + # Handle privileges. + if new_priv is not None: + curr_priv = privileges_get(cursor, user,host) + + # If the user has privileges on a db.table that doesn't appear at all in + # the new specification, then revoke all privileges on it. + for db_table, priv in curr_priv.iteritems(): + # If the user has the GRANT OPTION on a db.table, revoke it first. + if "GRANT" in priv: + grant_option = True + if db_table not in new_priv: + if user != "root" and "PROXY" not in priv and not append_privs: + privileges_revoke(cursor, user,host,db_table,grant_option) + changed = True + + # If the user doesn't currently have any privileges on a db.table, then + # we can perform a straight grant operation. + for db_table, priv in new_priv.iteritems(): + if db_table not in curr_priv: + privileges_grant(cursor, user,host,db_table,priv) + changed = True + + # If the db.table specification exists in both the user's current privileges + # and in the new privileges, then we need to see if there's a difference. + db_table_intersect = set(new_priv.keys()) & set(curr_priv.keys()) + for db_table in db_table_intersect: + priv_diff = set(new_priv[db_table]) ^ set(curr_priv[db_table]) + if (len(priv_diff) > 0): + privileges_revoke(cursor, user,host,db_table,grant_option) + privileges_grant(cursor, user,host,db_table,new_priv[db_table]) + changed = True + + return changed + +def user_delete(cursor, user, host): + cursor.execute("DROP USER %s@%s", (user,host)) + return True + +def privileges_get(cursor, user,host): + """ MySQL doesn't have a better method of getting privileges aside from the + SHOW GRANTS query syntax, which requires us to then parse the returned string. + Here's an example of the string that is returned from MySQL: + + GRANT USAGE ON *.* TO 'user'@'localhost' IDENTIFIED BY 'pass'; + + This function makes the query and returns a dictionary containing the results. + The dictionary format is the same as that returned by privileges_unpack() below. + """ + output = {} + cursor.execute("SHOW GRANTS FOR %s@%s", (user,host)) + grants = cursor.fetchall() + + def pick(x): + if x == 'ALL PRIVILEGES': + return 'ALL' + else: + return x + + for grant in grants: + res = re.match("GRANT (.+) ON (.+) TO '.+'@'.+'( IDENTIFIED BY PASSWORD '.+')? ?(.*)", grant[0]) + if res is None: + module.fail_json(msg="unable to parse the MySQL grant string") + privileges = res.group(1).split(", ") + privileges = [ pick(x) for x in privileges] + if "WITH GRANT OPTION" in res.group(4): + privileges.append('GRANT') + db = res.group(2) + output[db] = privileges + return output + +def privileges_unpack(priv): + """ Take a privileges string, typically passed as a parameter, and unserialize + it into a dictionary, the same format as privileges_get() above. We have this + custom format to avoid using YAML/JSON strings inside YAML playbooks. Example + of a privileges string: + + mydb.*:INSERT,UPDATE/anotherdb.*:SELECT/yetanother.*:ALL + + The privilege USAGE stands for no privileges, so we add that in on *.* if it's + not specified in the string, as MySQL will always provide this by default. + """ + output = {} + for item in priv.split('/'): + pieces = item.split(':') + if pieces[0].find('.') != -1: + pieces[0] = pieces[0].split('.') + for idx, piece in enumerate(pieces): + if pieces[0][idx] != "*": + pieces[0][idx] = "`" + pieces[0][idx] + "`" + pieces[0] = '.'.join(pieces[0]) + + output[pieces[0]] = [ g.strip() for g in pieces[1].upper().split(',') ] + + if '*.*' not in output: + output['*.*'] = ['USAGE'] + + return output + +def privileges_revoke(cursor, user,host,db_table,grant_option): + if grant_option: + query = "REVOKE GRANT OPTION ON %s FROM '%s'@'%s'" % (db_table,user,host) + cursor.execute(query) + query = "REVOKE ALL PRIVILEGES ON %s FROM '%s'@'%s'" % (db_table,user,host) + cursor.execute(query) + +def privileges_grant(cursor, user,host,db_table,priv): + + priv_string = ",".join(filter(lambda x: x != 'GRANT', priv)) + query = "GRANT %s ON %s TO '%s'@'%s'" % (priv_string,db_table,user,host) + if 'GRANT' in priv: + query = query + " WITH GRANT OPTION" + cursor.execute(query) + + +def strip_quotes(s): + """ Remove surrounding single or double quotes + + >>> print strip_quotes('hello') + hello + >>> print strip_quotes('"hello"') + hello + >>> print strip_quotes("'hello'") + hello + >>> print strip_quotes("'hello") + 'hello + + """ + single_quote = "'" + double_quote = '"' + + if s.startswith(single_quote) and s.endswith(single_quote): + s = s.strip(single_quote) + elif s.startswith(double_quote) and s.endswith(double_quote): + s = s.strip(double_quote) + return s + + +def config_get(config, section, option): + """ Calls ConfigParser.get and strips quotes + + See: http://dev.mysql.com/doc/refman/5.0/en/option-files.html + """ + return strip_quotes(config.get(section, option)) + + +def _safe_cnf_load(config, path): + + data = {'user':'', 'password':''} + + # read in user/pass + f = open(path, 'r') + for line in f.readlines(): + line = line.strip() + if line.startswith('user='): + data['user'] = line.split('=', 1)[1].strip() + if line.startswith('password=') or line.startswith('pass='): + data['password'] = line.split('=', 1)[1].strip() + f.close() + + # write out a new cnf file with only user/pass + fh, newpath = tempfile.mkstemp(prefix=path + '.') + f = open(newpath, 'wb') + f.write('[client]\n') + f.write('user=%s\n' % data['user']) + f.write('password=%s\n' % data['password']) + f.close() + + config.readfp(open(newpath)) + os.remove(newpath) + return config + +def load_mycnf(): + config = ConfigParser.RawConfigParser() + mycnf = os.path.expanduser('~/.my.cnf') + if not os.path.exists(mycnf): + return False + try: + config.readfp(open(mycnf)) + except (IOError): + return False + except: + config = _safe_cnf_load(config, mycnf) + + # We support two forms of passwords in .my.cnf, both pass= and password=, + # as these are both supported by MySQL. + try: + passwd = config_get(config, 'client', 'password') + except (ConfigParser.NoOptionError): + try: + passwd = config_get(config, 'client', 'pass') + except (ConfigParser.NoOptionError): + return False + + # If .my.cnf doesn't specify a user, default to user login name + try: + user = config_get(config, 'client', 'user') + except (ConfigParser.NoOptionError): + user = getpass.getuser() + creds = dict(user=user,passwd=passwd) + return creds + +def connect(module, login_user, login_password): + if module.params["login_unix_socket"]: + db_connection = MySQLdb.connect(host=module.params["login_host"], unix_socket=module.params["login_unix_socket"], user=login_user, passwd=login_password, db="mysql") + else: + db_connection = MySQLdb.connect(host=module.params["login_host"], port=int(module.params["login_port"]), user=login_user, passwd=login_password, db="mysql") + return db_connection.cursor() + +# =========================================== +# Module execution. +# + +def main(): + module = AnsibleModule( + argument_spec = dict( + login_user=dict(default=None), + login_password=dict(default=None), + login_host=dict(default="localhost"), + login_port=dict(default="3306"), + login_unix_socket=dict(default=None), + user=dict(required=True, aliases=['name']), + password=dict(default=None), + host=dict(default="localhost"), + state=dict(default="present", choices=["absent", "present"]), + priv=dict(default=None), + append_privs=dict(type="bool", default="no"), + check_implicit_admin=dict(default=False), + auth_plugin=dict(default=None) + ) + ) + user = module.params["user"] + password = module.params["password"] + host = module.params["host"] + state = module.params["state"] + priv = module.params["priv"] + check_implicit_admin = module.params['check_implicit_admin'] + append_privs = module.boolean(module.params["append_privs"]) + auth_plugin = module.params['auth_plugin'] + + if not mysqldb_found: + module.fail_json(msg="the python mysqldb module is required") + + if priv is not None: + try: + priv = privileges_unpack(priv) + except: + module.fail_json(msg="invalid privileges string") + + # Either the caller passes both a username and password with which to connect to + # mysql, or they pass neither and allow this module to read the credentials from + # ~/.my.cnf. + login_password = module.params["login_password"] + login_user = module.params["login_user"] + if login_user is None and login_password is None: + mycnf_creds = load_mycnf() + if mycnf_creds is False: + login_user = "root" + login_password = "" + else: + login_user = mycnf_creds["user"] + login_password = mycnf_creds["passwd"] + elif login_password is None or login_user is None: + module.fail_json(msg="when supplying login arguments, both login_user and login_password must be provided") + + cursor = None + try: + if check_implicit_admin: + try: + cursor = connect(module, 'root', '') + except: + pass + + if not cursor: + cursor = connect(module, login_user, login_password) + except Exception, e: + module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or ~/.my.cnf has the credentials") + + if state == "present": + if user_exists(cursor, user, host): + changed = user_mod(cursor, user, host, password, priv, append_privs, auth_plugin) + else: + if (password is None and auth_plugin is None) or (password is not None and auth_plugin is not None): + module.fail_json(msg="password xor auth_plugin is required when adding a user") + changed = user_add(cursor, user, host, password, priv, auth_plugin) + elif state == "absent": + if user_exists(cursor, user, host): + changed = user_delete(cursor, user, host) + else: + changed = False + module.exit_json(changed=changed, user=user) + +# this is magic, see lib/ansible/module_common.py +#<<INCLUDE_ANSIBLE_MODULE_COMMON>> +main() diff --git a/lib/modules/openldap b/lib/modules/openldap new file mode 100644 index 0000000..7293b23 --- /dev/null +++ b/lib/modules/openldap @@ -0,0 +1,434 @@ +#!/usr/bin/python + +# Manage OpenLDAP databases +# Copyright (c) 2013 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import ldap, ldap.sasl +from ldap.filter import filter_format +from ldap.dn import dn2str,explode_dn,str2dn +from ldap.modlist import addModlist +from ldif import LDIFParser +from functools import partial +import re, pwd +import tempfile, atexit + + +# Dirty hack to check equality between the targetted LDIF and that +# currently in the directory. The value of some configuration (olc*) +# attributes is automatically indexed when added; for those we'll add +# explicit indices to what we find in the LDIF. +indexedAttributes = frozenset([ + 'olcAttributeTypes', + 'olcObjectClasses', + 'olcAccess', + 'olcSyncrepl', + 'olcOverlay', + 'olcLimits', +]) + + +# Another hack. Configuration entries sometimes pollutes the DNs with +# indices, thus it's not possible to directly use them as base. +# Instead, we use their parent as a pase, and search for the *unique* +# match with the same ObjectClass and the matching extra attributes. +# ('%s' in the attribute value is replaced with the value of the source +# entry.) +indexedDN = { + 'olcSchemaConfig': [('cn', '{*}%s')], + 'olcHdbConfig': [('olcDbDirectory', '%s' )], + 'olcOverlayConfig': [('olcOverlay', '%s' )], +} + +# Allow for flexible ACLs for user using SASL's EXTERNAL mechanism. +# "username=postfix,cn=peercred,cn=external,cn=auth" is replaced by +# "gidNumber=106+uidNumber=102,cn=peercred,cn=external,cn=auth" where +# 102 is postfix's UID and 106 its primary GID. +# (Regular expressions are not allowed.) +sasl_ext_re = re.compile( r"""(?P<start>\sby\s+dn(?:\.exact)?)= + (?P<quote>['\"]?)username=(?P<user>[a-z][-a-z0-9_]*), + (?P<end>cn=peercred,cn=external,cn=auth) + (?P=quote)\s""" + , re.VERBOSE ) +multispaces = re.compile( r"\s+" ) +pwd_dict = {} + +def acl_sasl_ext(m): + u = m.group('user') + if u not in pwd_dict.keys(): + pwd_dict[u] = pwd.getpwnam(u) + return '%s="gidNumber=%d+uidNumber=%d,%s" ' % ( m.group('start') + , pwd_dict[u].pw_gid + , pwd_dict[u].pw_uid + , m.group('end') + ) + + +# Run the given callback on each DN seen. If its return value is not +# None, update the changed variable. +class LDIFCallback(LDIFParser): + def __init__(self, module, input, callback): + LDIFParser.__init__(self,input) + self.callback = callback + self.changed = False + + def handle(self,dn,entry): + b = self.callback(dn,entry) + if b is not None: + self.changed |= b + + +# Run slapcat(8) on the given suffix or DB number (suffix takes +# precedence) with an optional filter. (This is useful for offline +# searches, or one needs to bypass ACLs.) Returns an open pipe to the +# subprocess. +def slapcat(filter=None, suffix=None, idx=0): + cmd = [ os.path.join(os.sep, 'usr', 'sbin', 'slapcat') ] + + if filter is not None: + cmd.extend([ '-a', filter ]) + + if suffix is not None: + if type(suffix) is not str: + suffix = dn2str(suffix) + cmd.extend([ '-b', suffix ]) + else: + cmd.append( '-n%d' % idx ) + + return subprocess.Popen( cmd, stdout=subprocess.PIPE + , stderr=open(os.devnull, 'wb') ) + + +# Start / stop / whatever a service. +def service(name, state): + cmd = [ os.path.join(os.sep, 'usr', 'sbin', 'service'), name, state ] + subprocess.check_call( cmd, stdout=open(os.devnull, 'wb') + , stderr=subprocess.STDOUT ) + + +# Check if the given dn is already present in the directory. +# Returns None if doesn't exist, and give the dn,entry otherwise +def flexibleSearch(module, l, dn, entry): + idxClasses = set(entry['objectClass']).intersection(indexedDN.keys()) + if not idxClasses: + base = dn + scope = ldap.SCOPE_BASE + f = 'objectClass=*' + else: + # Search on the parent instead, and try to use a precise filter + dn = str2dn(dn) + h,t,_ = dn.pop(0)[0] + base = dn2str(dn) + scope = ldap.SCOPE_ONELEVEL + f = [] + for c in idxClasses: + f.append ( filter_format('objectClass=%s', [c]) ) + for a,v in indexedDN[c]: + if a == h: + v2 = t + elif a not in entry.keys() or len(entry[a]) > 1: + module.fail_json(msg="Multiple values found! This is a bug. Please report.") + else: + v2 = entry[a][0] + f.append ( filter_format(a+'='+v, [v2]) ) + if len(f) == 1: + f = f[0] + else: + f = '(&(' + ')('.join(f) + '))' + + r = l.search_s( base, scope, filterstr=f ) + if len(r) > 1: + module.fail_json(msg="Multiple results found! This is a bug. Please report.") + elif r: + return r.pop() + + +# Add or modify (only the attributes that differ from those in the +# directory) the entry for that DN. +# l must be an LDAPObject, and should provide an open connection to the +# directory with disclose/search/write access. +def processEntry(module, l, dn, entry): + changed = False + + for x in indexedAttributes.intersection(entry.keys()): + # remove useless extra spaces in ACLs etc + entry[x] = map( partial(multispaces.sub, ' '), entry[x] ) + + r = flexibleSearch( module, l, dn, entry ) + if r is None: + changed = True + if module.check_mode: + module.exit_json(changed=changed, msg="add DN %s" % dn) + if 'olcAccess' in entry.keys(): + # replace "username=...,cn=peercred,cn=external,cn=auth" + # by a DN with proper gidNumber and uidNumber + entry['olcAccess'] = map ( partial(sasl_ext_re.sub, acl_sasl_ext) + , entry['olcAccess'] ) + l.add_s( dn, addModlist(entry) ) + else: + d,e = r + fst = str2dn(dn).pop(0)[0][0] + diff = [] + for a,v in e.iteritems(): + if a not in entry.keys(): + if a != fst: + # delete all values except for the first attribute, + # which is implicit + diff.append(( ldap.MOD_DELETE, a, None )) + elif a in indexedAttributes: + if a == 'olcAccess': + # replace "username=...,cn=peercred,cn=external,cn=auth" + # by a DN with proper gidNumber and uidNumber + entry[a] = map ( partial(sasl_ext_re.sub, acl_sasl_ext) + , entry[a] ) + # add explicit indices in the entry from the LDIF + entry[a] = map( (lambda x: '{%d}%s' % x) + , zip(range(len(entry[a])),entry[a]) ) + if v != entry[a]: + diff.append(( ldap.MOD_REPLACE, a, entry[a] )) + elif v != entry[a]: + # for non-indexed attribute, we update values in the + # symmetric difference only + s1 = set(v) + s2 = set(entry[a]) + if s1.isdisjoint(s2): + # replace the former values with the new ones + diff.append(( ldap.MOD_REPLACE, a, entry[a] )) + else: + x = list(s1.difference(s2)) + if x: + diff.append(( ldap.MOD_DELETE, a, x )) + y = list(s2.difference(s1)) + if y: + diff.append(( ldap.MOD_ADD, a, y )) + + # add attributes that weren't in e + for a in set(entry).difference(e.keys()): + diff.append(( ldap.MOD_ADD, a, entry[a] )) + + if diff: + changed = True + if module.check_mode: + module.exit_json(changed=changed, msg="mod DN %s" % dn) + l.modify_s( d, diff ) + return changed + + +# Load the given module. +def loadModule(module, l, name): + changed = False + + f = filter_format( '(&(objectClass=olcModuleList)(olcModuleLoad=%s))', [name] ) + r = l.search_s( 'cn=config', ldap.SCOPE_ONELEVEL, filterstr = f, attrlist = [''] ) + + if not r: + changed = True + if module.check_mode: + module.exit_json(changed=changed, msg="add module %s" % name) + l.modify_s( 'cn=module{0},cn=config' + , [(ldap.MOD_ADD, 'olcModuleLoad', name)] ) + + return changed + + +# Find the database associated with a given attribute (eg, +# olcDbDirectory or olcSuffix). +def getDN_DB(module, l, a, v, attrlist=['']): + f = filter_format( '(&(objectClass=olcDatabaseConfig)('+a+'=%s))', [v] ) + return l.search_s( 'cn=config' + , ldap.SCOPE_ONELEVEL + , filterstr = f + , attrlist = attrlist ) + + +# Clear the given DB directory and delete the associated database. Fail +# if non empty, unless all existing DNS are in skipdns. +def wontRemove(module, skipdns, d, _): + if d not in skipdns: + module.fail_json(msg="won't remove '%s'" % d) +def removeDB(module, dbdir, skipdn=None): + changed = False + if not os.path.exists(dbdir): + return False + + l = ldap.initialize( 'ldapi://' ) + l.sasl_interactive_bind_s('', ldap.sasl.external()) + r = getDN_DB( module, l, 'olcDbDirectory', dbdir, attrlist=['olcSuffix'] ) + l.unbind_s() + + if len(r) > 1: + module.fail_json(msg="Multiple results found! This is a bug. Please report.") + elif r: + dn,entry = r.pop() + suffix = entry['olcSuffix'][0] + + skipdns = [suffix] + if skipdn is not None: + skipdns.extend([ "%s,%s" % (s,suffix) for s in skipdn ]) + # here we need to use slapcat not search_s, because we may + # not have read access on the database (even though we're + # root!). + p = slapcat( suffix=suffix ) + parser = LDIFCallback( module, p.stdout + , partial(wontRemove,module,skipdns) ) + parser.parse() + + changed = True + if module.check_mode: + module.exit_json(changed=changed, msg="remove dir %s" % dbdir) + + # slapd doesn't support database deletion, so we need to turn it + # off and remove it from slapd.d manually. + service( 'slapd', 'stop' ) + path = [ os.sep, 'etc', 'ldap', 'slapd.d' ] + ldif = explode_dn(dn)[::-1] + ldif[-1] += ".ldif" + path.extend( ldif ) + os.unlink( os.path.join(*path) ) + + # delete all children in path, but not the path directory itself. + for file in os.listdir(dbdir): + os.unlink( os.path.join(dbdir, file) ) + service( 'slapd', 'start' ) + return changed + + +# Convert a *.schema file into *.ldif format. The algorithm can be found +# in /etc/ldap/schema/openldap.ldif . +def slapd_to_ldif(src, name): + s = open( src, 'r' ) + d = tempfile.NamedTemporaryFile(delete=False) + atexit.register(lambda: os.unlink( d.name )) + + d.write('dn: cn=%s,cn=schema,cn=config\n' % name) + d.write('objectClass: olcSchemaConfig\n') + + re1 = re.compile( r'^objectIdentifier\s(.*)', re.I ) + re2 = re.compile( r'^objectClass\s(.*)', re.I ) + re3 = re.compile( r'^attributeType\s(.*)', re.I ) + reSp = re.compile( r'^\s+' ) + for line in s.readlines(): + if line == '\n': + line = '#\n' + m1 = re1.match(line) + m2 = re2.match(line) + m3 = re3.match(line) + if m1 is not None: + line = 'olcObjectIdentifier: %s' % m1.group(1) + elif m2 is not None: + line = 'olcObjectClasses: %s' % m2.group(1) + elif m3 is not None: + line = 'olcAttributeTypes: %s' % m3.group(1) + + d.write( reSp.sub(line, ' ') ) + + + s.close() + d.close() + return d.name + + +def main(): + module = AnsibleModule( + argument_spec = dict( + dbdirectory = dict( default=None ), + ignoredn = dict( default=None ), + state = dict( default="present", choices=["absent", "present"]), + target = dict( default=None ), + module = dict( default=None ), + suffix = dict( default=None ), + format = dict( default="ldif", choices=["ldif","slapd.conf"] ), + name = dict( default=None ), + ), + supports_check_mode=True + ) + + params = module.params + state = params['state'] + dbdirectory = params['dbdirectory'] + ignoredn = params['ignoredn'] + target = params['target'] + mod = params['module'] + suffix = params['suffix'] + form = params['format'] + name = params['name'] + + if ignoredn is not None: + ignoredn = ignoredn.split(':') + + changed = False + try: + if state == "absent": + if dbdirectory is not None: + changed = removeDB(module,dbdirectory,skipdn=ignoredn) + # TODO: might be useful to be able remove DNs + else: + module.fail_json(msg="missing dbdirectory") + + elif state == "present": + if form == 'slapd.conf': + if name is None: + module.fail_json(msg="missing name") + target = slapd_to_ldif(target, name) + + if target is None and mod is None: + module.fail_json(msg="missing target or module") + # bind only once per LDIF file for performance + l = ldap.initialize( 'ldapi://' ) + l.sasl_interactive_bind_s('', ldap.sasl.external()) + + if mod is None: + callback = partial(processEntry,module,l) + else: + changed |= loadModule (module, l, '%s.la' % mod) + if target is None and suffix is None: + l.unbind_s() + module.exit_json(changed=changed) + if target is None or suffix is None: + module.fail_json(msg="missing target or suffix") + r = getDN_DB(module, l, 'olcSuffix', suffix) + if not r: + module.fail_json(msg="No database found for suffix %s" % suffix) + elif len(r) > 1: + module.fail_json(msg="Multiple results found! This is a bug. Please report.") + else: + d = 'olcOverlay=%s,%s' % (mod, r.pop()[0]) + callback = lambda _,e: processEntry(module,l,d,e) + + parser = LDIFCallback( module, open(target, 'r'), callback ) + parser.parse() + changed = parser.changed + l.unbind_s() + + except subprocess.CalledProcessError, e: + module.fail_json(rv=e.returncode, msg=e.output.rstrip()) + except ldap.LDAPError, e: + e = e.args[0] + if 'info' in e.keys(): + msg = e['info'] + elif 'desc' in e.keys(): + msg = e['desc'] + else: + msg = str(e) + module.fail_json(msg=msg) + except KeyError, e: + module.fail_json(msg=str(e)) + + module.exit_json(changed=changed) + + +# this is magic, see lib/ansible/module_common.py +#<<INCLUDE_ANSIBLE_MODULE_COMMON>> +main() diff --git a/lib/modules/postmap b/lib/modules/postmap new file mode 100644 index 0000000..7080b25 --- /dev/null +++ b/lib/modules/postmap @@ -0,0 +1,109 @@ +#!/usr/bin/python + +# Create or update postfix's alias and lookup tables +# Copyright (c) 2013 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +try: + import selinux + HAVE_SELINUX=True +except ImportError: + HAVE_SELINUX=False + + +# Look up for the file suffix corresponding to 'db'. If 'db' is unset, +# pick the default_detabase_type of the given instance instead. +def file_suffix(instance, db): + if not db: + if instance: + cmd = [ os.path.join(os.sep, 'usr', 'sbin', 'postmulti') + , '-x' + , '-i', instance + , '--' + ] + else: + cmd = [] + cmd.extend([ os.path.join(os.sep, 'usr', 'sbin', 'postconf') + , '-h', 'default_database_type' ]) + null = open (os.devnull, 'wb') + db = subprocess.check_output(cmd, stderr=null).rstrip() + null.closed + + # See postmap(1) and postalias(1) + suffixes = { 'btree': 'db', 'cdb': 'cdb', 'hash': 'db' } + return suffixes[db] + + +# Compile the given (alias/lookup) table +def compile(cmd, instance, db, src): + cmd = [ os.path.join(os.sep, 'usr', 'sbin', cmd) ] + if instance: + config = os.path.join(os.sep, 'etc', 'postfix-%s' % instance) + cmd.extend([ '-c', config ]) + + if db: + src = "%s:%s" % (db,src) + + cmd.append(src) + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + + +def main(): + module = AnsibleModule( + argument_spec = dict( + src = dict( required=True ), + db = dict( choices=['btree','cdb','hash'] ), + cmd = dict( choices=['postmap','postalias'], default='postmap' ), + instance = dict( required=False ) + ), + add_file_common_args=True, + supports_check_mode=True + ) + + params = module.params + src = params['src'] + db = params['db'] + cmd = params['cmd'] + instance = params['instance'] + + if os.path.isabs(src): + src = src + else: + module.fail_json(msg="absolute paths are required") + + if not os.path.exists(src): + module.fail_json(src=src, msg="no such file") + + try: + dst = "%s.%s" % (src, file_suffix(instance, db)) + params['dest'] = dst + file_args = module.load_file_common_arguments(params) + + changed = False + msg = None + if not os.path.exists(dst) or os.path.getmtime(dst) <= os.path.getmtime(src): + changed = True + if not module.check_mode: + msg = compile( cmd, instance, db, src) + except subprocess.CalledProcessError, e: + module.fail_json(rv=e.returncode, msg=e.output.rstrip()) + + changed = module.set_file_attributes_if_different(file_args, changed) + module.exit_json(changed=changed, msg=msg) + + +# this is magic, see lib/ansible/module_common.py +#<<INCLUDE_ANSIBLE_MODULE_COMMON>> +main() diff --git a/lib/modules/postmulti b/lib/modules/postmulti new file mode 100644 index 0000000..d6ecb09 --- /dev/null +++ b/lib/modules/postmulti @@ -0,0 +1,103 @@ +#!/usr/bin/python + +# Create and manage postfix instances. +# Copyright (c) 2013 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +# Look up postfix configuration variable +def postconf(k, instance=None): + if instance: + cmd = [ os.path.join(os.sep, 'usr', 'sbin', 'postmulti') + , '-x' + , '-i', instance + , '--' + ] + else: + cmd = [] + + cmd.extend([ os.path.join(os.sep, 'usr', 'sbin', 'postconf') + , '-h', k ]) + return subprocess.check_output(cmd, stderr=subprocess.STDOUT).rstrip() + + +# To destroy an existing instance: +# postmulti -e disable -i mx +# postmulti -e destroy -i mx + +def main(): + module = AnsibleModule( + argument_spec = dict( + instance = dict( required=True ), + group = dict( required=False ) + ), + supports_check_mode=True + ) + + params = module.params + instance = params['instance'] + group = params['group'] + + changed=False + try: + enable = postconf('multi_instance_enable') + wrapper = postconf('multi_instance_wrapper') + + if enable != "yes" or not wrapper: + # Initiate postmulti + changed = True + if module.check_mode: + module.exit_json(changed=changed, msg="init postmulti") + cmd = [ os.path.join(os.sep, 'usr', 'sbin', 'postmulti') ] + cmd.extend([ '-e', 'init' ]) + subprocess.check_output(cmd, stderr=subprocess.STDOUT).rstrip() + + instances = postconf('multi_instance_directories').split() + if os.path.join(os.sep, 'etc', 'postfix-%s' % instance) not in instances: + changed = True + # Create the instance + + if module.check_mode: + module.exit_json(changed=changed, msg="create postmulti") + cmd = [ os.path.join(os.sep, 'usr', 'sbin', 'postmulti') ] + cmd.extend([ '-e', 'create' ]) + if group: + cmd.extend([ '-G', group ]) + cmd.extend([ '-I', 'postfix-%s' % instance ]) + subprocess.check_output(cmd, stderr=subprocess.STDOUT).rstrip() + + elif group != postconf('multi_instance_group', instance): + changed = True + + # Assign a new group, or remove the existing group + if module.check_mode: + module.exit_json(changed=changed, msg="assign group") + cmd = [ os.path.join(os.sep, 'usr', 'sbin', 'postmulti') ] + cmd.extend([ '-e', 'assign', '-i', 'postfix-%s' % instance ]) + if group: + cmd.extend([ '-G', group ]) + else: + cmd.extend([ '-G', '-' ]) + subprocess.check_output(cmd, stderr=subprocess.STDOUT).rstrip() + + module.exit_json(changed=changed) + + except subprocess.CalledProcessError, e: + module.fail_json(rv=e.returncode, msg=e.output.rstrip()) + + +# this is magic, see lib/ansible/module_common.py +#<<INCLUDE_ANSIBLE_MODULE_COMMON>> +main() |