--- /dev/null
+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
--- /dev/null
+#!/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
+ <BLANKLINE>
+ """
+ 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
+ <BLANKLINE>
+ """
+ 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)