summaryrefslogtreecommitdiffstats
path: root/lib/modules
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2014-07-06 19:10:59 +0200
committerGuilhem Moulin <guilhem@fripost.org>2015-06-07 02:52:31 +0200
commitf877db8c189fc0a0c43aa5df9303ad34cceb774e (patch)
treea472854d639de03d7338e2c00dd761a89a3fd382 /lib/modules
parenta427a868d388513da7b5386ba36f1898d7048dd0 (diff)
Move ansible modules to another directory.
Diffstat (limited to 'lib/modules')
-rw-r--r--lib/modules/mysql_user491
-rw-r--r--lib/modules/openldap434
-rw-r--r--lib/modules/postmap109
-rw-r--r--lib/modules/postmulti103
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()