From 4ed4fdcc01ff00c452af89313fd71a24410e37ef Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Sun, 12 Jun 2011 15:10:57 -0400 Subject: [PATCH] Add LDAP post. --- posts/LDAP.mdwn | 220 +++++++++++++++++++++++++++ posts/LDAP/abook-ldif-cleanup.py | 252 +++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 posts/LDAP.mdwn create mode 100755 posts/LDAP/abook-ldif-cleanup.py diff --git a/posts/LDAP.mdwn b/posts/LDAP.mdwn new file mode 100644 index 0000000..8d2899f --- /dev/null +++ b/posts/LDAP.mdwn @@ -0,0 +1,220 @@ +I'm using [LDAP][] ([RFC 4510][rfc4510]) to maintain a centralized +address book at home. Here are my setup notes, mostly following +Gentoo's [LDAP howto][howto]. + +Install [OpenLDAP][] with the `ldap` USE flag enabled: + + # emerge -av openldap + +If you get complaints about a `cyrus-sasl` ↔ `openldap` dependency +cycle, you should temporarily (or permanently) disable the `ldap` USE +flag for `cyrus-sasl`: + + # echo 'dev-libs/cyrus-sasl -ldap' > /etc/portage/package.use/ldap + # -ldap" emerge -av1 cyrus-sasl + # emerge -av openldap + +Generate an administrative password: + + $ slappasswd + New password: + Re-enter new password: + {SSHA}EzP6I82DZRnW+ou6lyiXHGxSpSOw2XO4 + +Configure the `slapd` LDAP server. Here is a very minimal +configuration, read the [OpenLDAP Admin Guide][admin] for details: + + # emacs /etc/openldap/slapd.conf + # cat /etc/openldap/slapd.conf + include /etc/openldap/schema/core.schema + include /etc/openldap/schema/cosine.schema + include /etc/openldap/schema/inetorgperson.schema + pidfile /var/run/openldap/slapd.pid + argsfile /var/run/openldap/slapd.args + database hdb + suffix "dc=example,dc=com" + checkpoint 32 30 + rootdn "cn=Manager,dc=example,dc=com" + rootpw {SSHA}EzP6I82DZRnW+ou6lyiXHGxSpSOw2XO4 + directory /var/lib/openldap-data + index objectClass eq + +Note that [inetorgperson][] is huge, but it's standardized. I think +it's better to pick a big standard right off, than to outgrow +something smaller and need to migrate. + +Gentoo creates the default database directory for you, so you can ignore warnings about needing to create it yourself. + +Configure LDAP client access. Again, read the docs for details on +adapting this to your particular situation: + + # emacs /etc/openldap/ldap.conf + $ cat /etc/openldap/ldap.conf + BASE dc=example,dc=com + URI ldap://ldapserver.example.com + +You can edit '/etc/conf.d/slapd' if you want command line options +passed to `slapd` when the service starts, but the defaults looked +fine to me. + +Start `slapd`: + + # /etc/init.d/slapd start + +Add it to your default runlevel: + + # eselect rc add /etc/init.d/slapd default + +Test the server with + + $ ldapsearch -x -b '' -s base '(objectclass=*)' + +Build a hierarchy in your database (this will depend on your +organizational structure): + + $ emacs /tmp/people.ldif + $ cat /tmp/people.ldif + version: 1 + + dn: dc=example, dc=com + objectClass: dcObject + objectClass: organization + o: Example, Inc. + dc: example + + dn: ou=people, dc=example,dc=com + objectClass: organizationalUnit + ou: people + description: All people in organisation + + dn: cn=Manager, dc=example,dc=com + objectClass: organizationalRole + cn: Manager + description: Directory Manager + $ ldapadd -D "cn=Manager,dc=example,dc=com" -xW -f /tmp/people.ldif + $ rm /tmp/people.ldif + +abook +----- + +If you currently keep your addresses in [abook][], you can export them +to [LDIF][] with: + + $ abook --convert --infile ~/.abook/addressbook --outformat ldif \ + | abook-ldif-cleanup.py --basedn 'ou=people,dc=example,dc=com' > dump.ldif + +where [[abook-ldif-cleanup.py]] does some compatibility processing +using the [python-ldap][] module. + +Add the people to your LDAP database: + + $ ldapadd -D "cn=Manager,dc=example,dc=com" -xW -f dump.ldif + +To check if that worked, you can list all the entries in your +database: + + $ ldapsearch -x -b 'dc=example,dc=com' '(objectclass=*)' + +Then remove the temporary files: + + $ rm -rf dump.ldif + +Aliases +------- + +Ok, we've put lots of people into the `people` OU, but what if we want +to assign them to another department? We can use aliases ([RFC +4512][rfc4512]), the symlinks of the LDAP world. To see how this +works, lets create a test OU to play with: + + $ emacs /tmp/test.ldif + $ cat /tmp/test.ldif + version: 1 + dn: ou=test, dc=example,dc=com + objectClass: organizationalUnit + ou: testing + $ ldapadd -D "cn=Manager,dc=example,dc=com" -xW -f /tmp/test.ldif + $ rm /tmp/test.ldif + +Now assign one of your people to that group: + + $ emacs /tmp/alias.ldif + $ cat /tmp/alias.ldif + version: 1 + dn: cn=Jane Doe, ou=test,dc=example,dc=com + objectClass: alias + aliasedObjectName: cn=Jane Doe, ou=people,dc=example,dc=com + $ ldapadd -D "cn=Manager,dc=example,dc=com" -xW -f /tmp/alias.ldif + $ rm /tmp/alias.ldif + +The `extensibleObject` class allows us to add the DN field, without it +you get: + + $ ldapadd -D "cn=Manager,dc=example,dc=com" -xW -f /tmp/alias.ldif + Enter LDAP Password: + adding new entry "cn=Jane Doe, ou=test,dc=example,dc=com" + ldap_add: Object class violation (65) + additional info: attribute 'cn' not allowed + +You can search for all entries (including aliases) with + + $ ldapsearch -x -b 'ou=test, dc=example,dc=com' '(objectclass=*)' + ... + dn: cn=Jane Doe,ou=test,dc=example,dc=com + objectClass: alias + objectClass: extensibleObject + aliasedObjectName:: Y249TWljaGVsIFZhbGxpw6hyZXMsb3U9cGVvcGxlLGRjPXRyZW1pbHksZGM9dXM= + ... + +You can control dereferencing with the `-a` option: + + $ ldapsearch -x -a always -b 'ou=test, dc=example,dc=com' '(objectclass=*)' + ... + dn: cn=Jane Doe,ou=people,dc=example,dc=com + cn: Jane Doe + sn: Doe + ... + +Once you've played around, you can remove the `test` OU and its +descendants: + + $ ldapdelete -D "cn=Manager,dc=example,dc=com" -xW -r ou=test,dc=example,dc=com + +shelldap +-------- + +There are a number of tools to make it easier to manage LDAP +databases. Command line junkies will probably like [shelldap][]: + + $ shelldap --server ldapserver.example.com + ~ > ls + cn=Manager + ou=people + ~ > cat cn=Manager + + dn: cn=Manager,dc=example,dc=com + objectClass: organizationalRole + cn: Manager + + ~ > cd ou=people + ou=people,~ > ls + +References +---------- + +There's a [good overview][schema] of schema and objectclasses by Brian +Jones on O'Reilly. If you want to use inetOrgPerson but also include +the countryName attribute, ... + +[LDAP]: http://en.wikipedia.org/wiki/LDAP +[rfc4510]: http://tools.ietf.org/html/rfc4510 +[howto]: http://www.gentoo.org/doc/en/ldap-howto.xml +[OpenLDAP]: http://www.openldap.org/ +[admin]: http://www.openldap.org/doc/admin/ +[inetorgperson]: http://www.apps.ietf.org/rfc/rfc2798.html +[abook]: http://abook.sourceforge.net/ +[LDIF]: http://en.wikipedia.org/wiki/LDAP_Data_Interchange_Format +[python-ldap]: http://www.python-ldap.org/ +[rfc4512]: http://tools.ietf.org/html/rfc4512 +[shelldap]: http://projects.martini.nu/shelldap/ +[schema]: http://www.oreillynet.com/pub/a/sysadmin/2006/11/09/demystifying-ldap-data.html diff --git a/posts/LDAP/abook-ldif-cleanup.py b/posts/LDAP/abook-ldif-cleanup.py new file mode 100755 index 0000000..5fd04f6 --- /dev/null +++ b/posts/LDAP/abook-ldif-cleanup.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python +# +# Copy... + +"""Cleanup the LDIF output from abook_ using `python-ldap`_. + +.. _abook: http://abook.sourceforge.net/ +.. _python-ldap: http://www.python-ldap.org/ +""" + +import re +import StringIO + +import ldif + + +def cleanup(text, basedn): + # pre-parser formatting + text = remove_trailing_mail(text) + text = remove_cn_commas(text) + + records = ldif.ParseLDIF(StringIO.StringIO(text)) + + # post-parser formatting + records = remove_empty_mail(records) + records = remove_top_objectclass(records) + records = add_inetorgperson_objectclass(records) + records = add_base_dn(records, basedn) + records = add_names(records) + records = standardize_phone_numbers(records) + records = standardize_country_code(records) + records = rename_locality(records) + records = rename_cellphone(records) + records = rename_xmozillaanyphone(records) + records = rename_xmozillanickname(records) + records = rename_homeurl(records) + records = set_postaladdress(records) + + # convert back to a string + s = StringIO.StringIO() + writer = ldif.LDIFWriter(s) + for dn,record in records: + writer.unparse(dn, record) + return 'version: 1\n\n%s' % s.getvalue() + +def remove_trailing_mail(text): + """ + >>> print(remove_trailing_mail('\\n'.join([ + ... 'version: 1', + ... 'dn: cn=John Doe,mail=', + ... 'cn: John Doe', + ... '', + ... ]))) + version: 1 + dn: cn=John Doe,mail=x@y.com + cn: John Doe + + """ + return re.sub(',mail=$', ',mail=x@y.com', text, flags=re.MULTILINE) + +def _sub_cn_commas(match): + cn = match.group(1).replace(',', '_') + return 'cn=%s,mail=' % cn + +def remove_cn_commas(text): + """ + >>> print(remove_cn_commas('\\n'.join([ + ... 'version: 1', + ... 'dn: cn=John, Jane, and Jim Doe,mail=x@y.com', + ... 'cn: John, Jane, and Jim Doe', + ... '', + ... ]))) + version: 1 + dn: cn=John_ Jane_ and Jim Doe,mail=x@y.com + cn: John, Jane, and Jim Doe + + """ + return re.sub('cn=(.*),mail=', _sub_cn_commas, text) + +def remove_empty_mail(records): + for dn,record in records: + if 'mail' in record and record['mail'] == ['']: + record.pop('mail') + return records + +def remove_top_objectclass(records): + for dn,record in records: + if 'top' in record['objectclass']: + record['objectclass'].remove('top') + return records + +def add_inetorgperson_objectclass(records): + for dn,record in records: + record['objectclass'].extend( + ['organizationalPerson', 'inetOrgPerson', 'extensibleObject']) + # extensibleObject required for countryName + return records + +def add_base_dn(records, basedn): + regexp = re.compile(',mail=.*') + subst = ', ' + basedn + for i,(dn,record) in enumerate(records): + new_dn = regexp.sub(subst, dn) + records[i] = (new_dn, record) + return records + +def _set_key(record, key, value, override=True): + """Case-agnostic value setter. + + >>> record = {'aB': 'old'} + >>> _set_key(record, 'AB', 'new') + >>> print record + """ + key = key.lower() + keys = [k for k in record.keys() if k.lower() == key.lower()] + if keys: + k = keys[0] + else: + k = key + if override or k not in record: + record[k] = value + +def add_names(records): + """ + Surname and givenName are defined in `RFC 4519`_. + + .. _RFC 4512: http://tools.ietf.org/html/rfc4519 + """ + for dn,record in records: + cn = record['cn'] + gn,sn = cn[0].rsplit(' ', 1) + _set_key(record, 'sn', [sn], override=False) + _set_key(record, 'givenName', [gn], override=False) + return records + +def standardize_phone_numbers(records): + """Standardize phone numbers to match `E.123`_ international notation + + Assumes numbers not starting with a '+' live in the USA. + + >>> import pprint + >>> records = [ + ... ('cn=John', {'homephone': '123-456-7890'}, + ... ('cn=Jane', {TODO})] + >>> pprint.pprint(standardize_phone_numbers(records)) + + .. _E.123: http://en.wikipedia.org/wiki/E.123 + """ + # TODO + return records + +def standardize_country_code(records): + # TODO + # ISO3166 + # http://tools.ietf.org/html/rfc4519 + # http://tools.ietf.org/html/rfc4517 + #USA US + #Canada CA + #Bermuda BM + #Bahamas BS + #Netherlands NL + table = { + 'USA': 'US', + 'Canada': 'CA', + 'Bermuda': 'BM', + 'Bahamas': 'BS', + 'Netherlands': 'NL', + } + for dn,record in records: + if 'countryname' in record: + record['countryname'] = [ + table.get(c, c) for c in record['countryname']] + return records + +def rename_locality(records): + # locality -> l (localityName) + for dn,record in records: + if 'locality' in record: + record['localityname'] = record.pop('locality') + return records + +def rename_cellphone(records): + # cellphone -> mobile + for dn,record in records: + if 'cellphone' in record: + record['mobile'] = record.pop('cellphone') + return records + +def rename_xmozillaanyphone(records): + # xmozillaanyphone -> telephonenumber + for dn,record in records: + if 'xmozillaanyphone' in record: + record['telephonenumber'] = record.pop('xmozillaanyphone') + return records + +def rename_xmozillanickname(records): + # xmozillanickname -> displayname + for dn,record in records: + if 'xmozillanickname' in record: + record['displayname'] = record.pop('xmozillanickname') + return records + +def rename_homeurl(records): + # homeurl -> labeledURI + for dn,record in records: + if 'homeurl' in record: + record['labeleduri'] = [ + '%s Home Page' % x for x in record.pop('homeurl')] + return records + +def set_postaladdress(records): + # postalAddress defined in rfc4517 + # homePostalAddress defined in ? + # streetAddress defined in rfc4519 + for dn,record in records: + street = record.get('streetaddress', [None])[0] + addr2 = record.get('streetaddress2', [None])[0] + locality = record.get('localityname', [None])[0] + state = record.get('st', [None])[0] + if locality: + ls = locality + if state: + ls += ', %s' % state + elif state: + ls = state + else: + ls = None + post = record.get('postalcode', [None])[0] + country = record.get('countryname', [None])[0] + if 'streetaddress2' in record: + record.pop('streetaddress2') + addr = '$'.join( + [line for line in [street, addr2, ls, post, country] if line]) + _set_key(record, 'homepostaladdress', [addr], override=False) + return records + + +if __name__ == '__main__': + import argparse + import sys + + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + '-b', '--basedn', dest='basedn', metavar='DNBASE', + default='ou=people,dc=example,dc=org', + help="Base distinguished name for the entries (%(default)s)") + + args = p.parse_args() + + text = sys.stdin.read() + text = cleanup(text, basedn=args.basedn) + sys.stdout.write(text) -- 2.26.2