bh: utils/ldap-sync COPYING, NONE, 1.1 LIESMICH.txt, NONE, 1.1 config-example.py, NONE, 1.1 converter.py, NONE, 1.1 filelock.py, NONE, 1.1 ldapsync.py, NONE, 1.1 transferrer.py, NONE, 1.1

cvs at kolab.org cvs at kolab.org
Wed Dec 13 12:06:49 CET 2006


Author: bh

Update of /kolabrepository/utils/ldap-sync
In directory doto:/tmp/cvs-serv3246

Added Files:
	COPYING LIESMICH.txt config-example.py converter.py 
	filelock.py ldapsync.py transferrer.py 
Log Message:
initial import of the ldap sync script

--- NEW FILE: COPYING ---
		    GNU GENERAL PUBLIC LICENSE
		       Version 2, June 1991

 Copyright (C) 1989, 1991 Free Software Foundation, Inc.
     59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

			    Preamble

  The licenses for most software are designed to take away your
freedom to share and change it.  By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users.  This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it.  (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.)  You can apply it to
your programs, too.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.

  To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.

  For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have.  You must make sure that they, too, receive or can get the
source code.  And you must show them these terms so they know their
rights.

  We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.

  Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software.  If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.

  Finally, any free program is threatened constantly by software
patents.  We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary.  To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.

  The precise terms and conditions for copying, distribution and
modification follow.

		    GNU GENERAL PUBLIC LICENSE
   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION

  0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License.  The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language.  (Hereinafter, translation is included without limitation in
the term "modification".)  Each licensee is addressed as "you".

Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope.  The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.

  1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.

You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.

  2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:

    a) You must cause the modified files to carry prominent notices
    stating that you changed the files and the date of any change.

    b) You must cause any work that you distribute or publish, that in
    whole or in part contains or is derived from the Program or any
    part thereof, to be licensed as a whole at no charge to all third
    parties under the terms of this License.

    c) If the modified program normally reads commands interactively
    when run, you must cause it, when started running for such
    interactive use in the most ordinary way, to print or display an
    announcement including an appropriate copyright notice and a
    notice that there is no warranty (or else, saying that you provide
    a warranty) and that users may redistribute the program under
    these conditions, and telling the user how to view a copy of this
    License.  (Exception: if the Program itself is interactive but
    does not normally print such an announcement, your work based on
    the Program is not required to print an announcement.)

These requirements apply to the modified work as a whole.  If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works.  But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.

Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.

In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.

  3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:

    a) Accompany it with the complete corresponding machine-readable
    source code, which must be distributed under the terms of Sections
    1 and 2 above on a medium customarily used for software interchange; or,

    b) Accompany it with a written offer, valid for at least three
    years, to give any third party, for a charge no more than your
    cost of physically performing source distribution, a complete
    machine-readable copy of the corresponding source code, to be
    distributed under the terms of Sections 1 and 2 above on a medium
    customarily used for software interchange; or,

    c) Accompany it with the information you received as to the offer
    to distribute corresponding source code.  (This alternative is
    allowed only for noncommercial distribution and only if you
    received the program in object code or executable form with such
    an offer, in accord with Subsection b above.)

The source code for a work means the preferred form of the work for
making modifications to it.  For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable.  However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.

If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.

  4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License.  Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.

  5. You are not required to accept this License, since you have not
signed it.  However, nothing else grants you permission to modify or
distribute the Program or its derivative works.  These actions are
prohibited by law if you do not accept this License.  Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.

  6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions.  You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.

  7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all.  For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.

If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.

It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices.  Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.

This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.

  8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded.  In such case, this License incorporates
the limitation as if written in the body of this License.

  9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time.  Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

Each version is given a distinguishing version number.  If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation.  If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.

  10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission.  For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this.  Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.

			    NO WARRANTY

  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.

  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.

		     END OF TERMS AND CONDITIONS

	    How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    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 2 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, write to the Free Software
    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA


Also add information on how to contact you by electronic and paper mail.

If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:

    Gnomovision version 69, Copyright (C) year  name of author
    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
    This is free software, and you are welcome to redistribute it
    under certain conditions; type `show c' for details.

The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License.  Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.

You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary.  Here is a sample; alter the names:

  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
  `Gnomovision' (which makes passes at compilers) written by James Hacker.

  <signature of Ty Coon>, 1 April 1989
  Ty Coon, President of Vice

This General Public License does not permit incorporating your program into
proprietary programs.  If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library.  If this is what you want to do, use the GNU Library General
Public License instead of this License.

--- NEW FILE: LIESMICH.txt ---
Anmerkungen zum ldapsync Script
===============================
2006-11-22, Bernhard Reiter <bernhard at intevation.de>


Systemvoraussetzungen
---------------------

Python       >=2.3
Python-LDAP  >=2.0

Getestet wurde u.A. mit:
Debian GNU/Linux, Python 2.3.5, python-ldap 2.0.4-1, libldap2 2.1.30-8, i586.
Windows Server  2003 (Standard Edition, SP1) with AD and Exchange in Vmware.


Installation
------------

Kopieren Sie den Inhalt des Verzeichnisses an eine geeignete Stelle.
Als Konfigurationsdatei dient eine Datei config.py im gleichen
Verzeichnis.  Als Vorlage für diese Datei kann config-example.py dienen,
welche auch einige Kommentare für die Dokumentation enthält.


Das Hauptprogramm ist ldapsync.py.  Zum Test kann die Option --dry-run
verwendet werden.
Die Optionen --search und --dump-data sind Analyse-Funktionen.


Logging
-------

Per default wird nach stderr geloggt mit Log-Level DEBUG.  Im Moment
kann man dies nur durch entsprechende Änderungen an der Funktion
initialize_logging in ldapsync.py ändern.


Zu Beachten
-----------

 - Datenverlust vermeiden:

   ldapsync geht davon aus, dass alle Einträge im Zielzweig des
   LDAP-Server von ldapsync verwaltet werden und löscht dort
   entsprechend Objekte, die im Quellzweig gelöscht wurden. Je nach
   Konfiguration kann das zum ungewollten Verlust von Daten führen.

   In beide Richtungen sollte also ein eigener Teilbaum für die
   Adresseinträge vorgesehen sein.

 

   In der anderen Richtung kann das Problematisch sein.  Das
   Kolab-Webfrontend hat ein Interface zum Verwalten von externen
   Addressen, dies im LDAP unter cn=external,<kolab-base-dn> ablegt.
   Wenn ldap sync seine Einträge ebenfalls dort ablegt, werden Einträge,
   die über Kolabs Webfrontend angelegt wurden gelöscht.  Es ist daher
   empfehlenswert, einen anderen Teilbaum zu verwenden.

 - Anlegen des Zielzweigs:

   Active Directory: Für jeden kolabHomeServer sollte 
   in der Standardkonfiguration ein Eintrag vorhanden sein:
	ou=kolab.server.example.org

   Kolab Servers OpenLDAP: Ein Teilbaum kann angelegt werden, z.B. mit
   ldapadd, wie folgt:

	Entsprechende Datei ad-subtree.ldif anlegen:
		dn: cn=ad,cn=external,dc=example,dc=org
		cn: ad
		objectClass: top
		objectClass: kolabNamedObject

       ldapadd -f ad-subtree.ldiff -H ldap://kolab.server.example.org \
	-D cn=manager,cn=internal,dc=example,dc=org -x -W


Bekannte Probleme/Anmerkungen
-----------------------------

 - Active Directory akzeptiert manchmal Einträge, schreibt diese
   aber nach wenigen Sekunden oder Minuten um. Nur wenn die richtige
   Kombination von Attributen gesetzt wird, 
   verbleibt beispielsweise das Attribut "showInAddressBook". 

 - Um durch AD hinzugefügte Attributewerte in relevanten Attributen
   nicht mitzuvergleichen, können diese hart entfernt werden.
   Siehe converter.py, Funktion remove_ad_extra_attributevalues().
   Die dort entfernten Attributewerte (zur Zeit X400 proxyAddresses)
   können nicht mehr zuverlässig gesetzt werden.
   
 - Sperrdatei (.lock) wird nicht weggeräumt, wenn der angegebene
   Prozess nicht mehr läuft. Wenn nach einem Rechner-Absturz
   die Sperrdatei noch da ist, dann weigert sich das Skript zu starten.
   Bei automatiserter Ausführung (z.B. per cron) 
   muss eine Fehlermeldung dem Admin also zugehen.

 - Active Directory akzeptiert nur manche geschützen ("quoted") Zeichen
   bei DNs. Das Skript versucht deshalb die DNs so zu normalisieren,
   dass sie bei Umlauten keine Probleme verursachen. 
   (Siehe Kommentare in converter.py für die technischen Details.)


--- NEW FILE: config-example.py ---
(This appears to be a binary file; contents omitted.)

--- NEW FILE: converter.py ---
# Copyright (C) 2006 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh at intevation.de>
# Bernhard Reiter <bernhard at intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with the software for details.

"""Classes and functions to convert an ldap entry"""

import logging
import re

import ldap

def joindn(exploded_dn):
    """Join an 'exploded' dn again.  This is the inverse to ldap.explode_dn"""
    return ",".join(exploded_dn)

def _change_maybe_hex_into_char(match):
    """Changes quoted hex into character if it is not a MUST dn escape char.

    The list of MUST quoted characters was taken from RFC4514.
    Takes a match object parameter for something like '\\a0'.  Returns '\xa0'.
    """
    hex=match.group(0).upper()
    if not hex in ['\\20','\\22','\\23', '\\2B','\\2C',
                   '\\3B', '\\3C', '\\3E', '\\5C']:
        return chr(int(match.group(0)[1:3],16))
    else:
        return hex

def unescape_maybe_quoted_utf8_chars(dnpartvalue):
    return re.sub("\\\\[0-9a-fA-F]{2}", _change_maybe_hex_into_char, dnpartvalue)

def normalize_dn(dn):
    """Returns a normalized version of the dn that AD can accept.

    The normalized version uses all lowercase of the types in the DN.
    E.g. 'cn=Foo,DC=example,dc=com' will become 'cn=Foo,dc=example,dc=com'.

    Also the OpenLDAP function ldap.explode_dn() will be used, which
    quotes some characters according to RFC 4514.  Note: ldap.explode_dn
    calls the OpenLDAP API function ldap_explode_dn which is deprecated
    in favor of ldap_str2dn.  However, ldap_str2dn has not been wrapped
    by python-ldap yet.

    Some experiments show that Active Directory from Windows Server 2003sp1,
    only seem to correctly accept the characters that MUST be coded,
    which happend to be ascii character, thus one byte long in utf8.
    So this function will remove the escapes done by ldap.explode_dn() again
    for the characters that MAYBE quoted before returning the dn.
    """
    normalized = []
    for item in ldap.explode_dn(dn):
        split = item.split("=")
        split[0] = split[0].lower()
        split[1] = unescape_maybe_quoted_utf8_chars(split[1])
        normalized.append("=".join(split))
    return ",".join(normalized)


def normalize_dns(entries):
    """Normalizes all dns in entries

    The parameter entries should be a dictionary mapping dns to
    attribute dictionaries, that is, the parameter should have the same
    structure as the result object from the ldap search.
    The keys in the entries dictionary will be replaced by their
    normalized version.  The normalization is done with
    normalize_dn. The entries dictionary is modified in place.
    """
    for dn in entries.keys():
        entries[normalize_dn(dn)] = entries.pop(dn)

def remove_ad_extra_attributevalues(entries):
    """Removes extra attributes that ad will add on its own.

    Active Directory might add extra attribute values by itself.
    If we do not want to see them for the comparision, this function
    can will them from entries in place.
    """
    for dn in entries.keys():
        if entries[dn].has_key("proxyAddresses"):
            entries[dn]["proxyAddresses"] = \
                filter(lambda value: value[:5]!="X400:",
                       entries[dn]["proxyAddresses"])


class ConversionError(Exception):

    """Exception raised by rules of the EntryConverter to indicate errors"""


class EntryConverter:

    """Class to convert an LDAP entry.

    An LDAP entry is defined by its dn and a dictionary of attributes.
    A converter consists of a list of converter rules.

    The converters rules are used to convert the attributes and dn of an
    entry.  Each entry in the list is applied in order.  A converter
    rule is a callable object that will be called like this:

       attribute_converter(OLD_DN, OLD_ATTRS, NEW_DN, NEW_ATTRS)

    OLD_DN is the old DN in 'exploded' form as returned by
    ldap.explode_dn, that is it is list of the individual elements of
    the dn. OLD_ATTRS is the dictionary with the old attributes and
    NEW_DN and NEW_ATTRS are the DN and attribute dictionary of the new
    entry.  A rule reads from OLD_DN and OLD_ATTRS and creates or
    modifies NEW_ATTRS and NEW_DN.  It should not modify OLD_ATTRS or
    OLD_DN.

    Usually a rule simply returns None.  However, a rule may return a
    list of (DN, ATTRS) pairs.  These pairs describe new LDAP entries
    which should be added to the LDAP server.

    A rule should raise a ConversionError to indicate an error.  If a
    rule raises such an exception, processing the rule stops and the
    convert method return None.
    """

    def __init__(self, rules):
        self.rules = rules

    def convert(self, dn, attrs, added_entries):
        """Converts an LDAP entry.

        The ldap entry is described by the dn as a string and attributes
        as a dictionary.  The parameters added_entries should be a list.
        If a rule returns a list with new LDAP entries the entries will
        be added at the end of that list.
        """
        exploded_dn = ldap.explode_dn(dn)
        new_attrs = {}
        new_dn = exploded_dn[:]
        try:
            for rule in self.rules:
                new_entries = rule(exploded_dn, attrs, new_dn, new_attrs)
                if new_entries:
                    added_entries.extend(new_entries)
            return joindn(new_dn), new_attrs
        except ConversionError, exc:
            logging.error("Cannot convert entry %r: %s" % (dn, exc))
            return None



def copy_attr(attrname, newattrname=None):
    """Creates an attribute converter that simply copies an attribute

    The source attribute is given by attrname.  The destination
    attribute by newattrname.  if newattrname is not given attrname is
    used as destination.
    """
    if newattrname is None:
        newattrname = attrname
    def convert(dn, attrs, newdn, newattrs):
        if attrname in attrs:
            newattrs[newattrname] = attrs[attrname]

    return convert

def set_attr(attrname, value):
    """Creates an attribute converter that sets an attribute to a constant

    The destination attribute is given by attrname.  The value should be
    a string or a list of strings.  The strings should be byte strings
    in UTF-8.
    """
    if isinstance(value, str):
        value = [value]
    def convert(dn, attrs, newdn, newattrs):
        newattrs[attrname] = value
    return convert

def create_targetAddress_from_mail(dn, attrs, newdn, newattrs):
    """Sets targetAddress in newattrs from the mail attribute in attrs"""
    mail = attrs["mail"][0]
    if mail:
        newattrs["targetAddress"] = ["SMTP:" + mail]

def create_proxyAddresses_from_alias(dn, attrs, newdn, newattrs):
    """Sets the proxyAddresses attribute from mail and alias in newattrs"""
    proxy_addresses = []
    for addr in attrs.get("alias", ()):
        proxy_addresses.append("smtp:" + addr)
    proxy_addresses.append("SMTP:" + attrs["mail"][0])
    newattrs["proxyAddresses"] = proxy_addresses

def create_alias_from_proxyAddresses(dn, attrs, newdn, newattrs):
    mail = attrs["mail"][0]
    aliases = []
    for item in attrs.get("proxyAddresses", ()):
        if item.lower().startswith("smtp:"):
            alias = item[5:]
            if alias != mail:
                aliases.append(alias)
    newattrs["alias"] = aliases

def create_new_entries_for_aliases(dn, attrs, newdn, newattrs):
    """Creates new ldap entries for mail aliases.

    The new entries are copies of the (newdn, newattrs) pair with
    aliases taken from the old attrs.  The cn of the new entries is the
    cn on newattrs with the mail address.
    """
    extra_entries = []
    for addr in attrs.get("alias", ()):
        extra_dn = newdn[:]
        extra_attrs = newattrs.copy()
        extra_dn[0] = extra_dn[0] + " (" + addr + ")"
        extra_attrs["mail"] = [addr]
        extra_attrs["alias"] = []
        extra_attrs["proxyAddresses"] = []
        extra_entries.append((joindn(extra_dn), extra_attrs))
    return extra_entries

def guess_sn(dn, attrs, newdn, newattrs):
    """If newattrs doesn't have an sn entry, set to the first word of the cn"""
    if "sn" not in newattrs:
        newattrs["sn"] = newattrs["cn"][0].split()[:1]


def new_base_dn(base_dn):
    """Creates a rule that changes the base of a dn

    The returned rule constructs the new DN by taking the first
    component of the old DN and appending the new base dn.
    """
    base_dn = ldap.explode_dn(normalize_dn(base_dn))

    def convert(dn, attrs, newdn, newattrs):
        newdn[:] = dn[:1] + base_dn

    return convert


def insert_kolabHomeServer_dn(dn, attrs, newdn, newattrs):
    """Inserts kolabHomeServer attribute as an ou DN part into newdn at index 1
    """
    newdn.insert(1, "ou=" + attrs["kolabHomeServer"][0])

def compare_old_attrs(attr1, attr2):
    """Creates a rule that compares attributes in the old attributes dictionary

    The returned rule, compares both attributes if they exist and if
    their values differ, it logs a warning
    """
    def compare(dn, attrs, newdn, newattrs):
        if attr1 in attrs and attr2 in attrs:
            if attrs[attr1] != attrs[attr2]:
                logging.warn("%r: %r:%r but %r:%r"
                             % (joindn(dn), attr1, attrs[attr1],
                                attr2, attrs[attr2]))
    return compare

--- NEW FILE: filelock.py ---
# Copyright (C) 2006 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh at intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with the software for details.

"""Locking with a lockfile"""

import sys
import os

import logging


class FileLock:

    """Lock resources using a lock file.

    When acquiring the lock, the file is created with the the
    os.O_CREAT|os.O_EXCL options so that creating and opening the file
    will only succeed if the file does not exist yet.  The pid of the
    current process is written to the file.  The FileLock class itself
    does nothing with that information but it may help debugging.
    """

    def __init__(self, directory, lockname):
        self.lockfile = os.path.join(directory, lockname)

    def acquire(self):
        logging.debug("creating lockfile %r", self.lockfile)
        try:
            fd = os.open(self.lockfile, os.O_CREAT|os.O_EXCL|os.O_RDWR)
        except (IOError, OSError), e:
            logging.error("Could not create lockfile %r: %s", self.lockfile, e,
                          exc_info=True)
            sys.exit(1)
        os.write(fd, "%s\n" % os.getpid())
        os.close(fd)

    def release(self):
        logging.debug("removing lockfile %r", self.lockfile)
        try:
            os.unlink(self.lockfile)
        except (IOError, OSError), e:
            logging.error("Error while trying to remove lockfile %r: %s",
                          self.lockfile, e, exc_info=True)
            sys.exit(1)

--- NEW FILE: ldapsync.py ---
# Copyright (C) 2006 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh at intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with the software for details.

"""Sync entries between a Kolab LDAP server and Active Directory"""

import sys
import os
import logging
import optparse

import ldap
import ldif

import filelock
import transferrer
import converter


class config:

    def load(cls, filename):
        variables = {}
        execfile(os.path.join(os.path.dirname(__file__), filename),
                 variables)
        for varname in ["workdir", "kolabserver", "adserver",
                        "kolab_to_ad_converter_rules",
                        "ad_to_kolab_converter_rules"]:
            setattr(cls, varname, variables[varname])
    load = classmethod(load)

    # If true, do not write to an LDAP server
    dry_run = False

    # If true, dump data during the transfer for debugging purposes
    dump_data = False


def check_work_dir(workdir):
    """Check that the working directory exists"""
    if not os.path.isdir(workdir):
        logging.error("working directory %r does not exist", workdir)
        sys.exit(1)



def perform_sync():
    """Perform the sync.

    If the dry_run parameter is given and true, no actual write
    operations will take place on the server.
    """
    logging.info("start sync process")
    logging.info("transferring from kolab to active directory")
    conv = converter.EntryConverter(config.kolab_to_ad_converter_rules)
    T = transferrer.Transferrer(config.kolabserver, config.adserver, conv,
                                dry_run=config.dry_run,
                                dump_data=config.dump_data)
    T.transfer()
    logging.info("transfer from kolab to active directory finished")

    logging.info("transferring from active directory to kolab")
    conv = converter.EntryConverter(config.ad_to_kolab_converter_rules)
    T = transferrer.Transferrer(config.adserver, config.kolabserver, conv,
                                dry_run=config.dry_run,
                                dump_data=config.dump_data)
    T.transfer()
    logging.info("transfer from active directory to kolab finished")

    logging.info("finished sync process")


def sync_ldap_servers():
    """Performs the sync while while holding the lock

    If the dry_run parameter is given and true, no actual write
    operations will take place on the server.
    """
    check_work_dir(config.workdir)
    lock = filelock.FileLock(config.workdir, "ldapsync.lock")
    lock.acquire()
    try:
        perform_sync()
    finally:
        lock.release()


def initialize_logging():
    """Initializes the logging system for the ldap sync script"""
    root = logging.getLogger()
    root.setLevel(logging.DEBUG)
    hdlr = logging.StreamHandler()
    fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
    hdlr.setFormatter(fmt)
    root.addHandler(hdlr)


def search(server_type, search_type):
    server = getattr(config, server_type + "server", None)
    if server is None:
        print >>sys.stderr, "unknown server type %r" % server_type
        sys.exit(1)

    search = getattr(server, search_type + "_search", None)
    if search is None:
        print >>sys.stderr, "unknown search type %r" % search_type
        sys.exit(1)

    writer = ldif.LDIFWriter(sys.stdout)
    for dn, record in server.search(search):
        if dn is not None:
            writer.unparse(dn, record)


def main():
    parser = optparse.OptionParser()
    parser.set_defaults(action="sync",
                        dry_run=config.dry_run,
                        dump_data=config.dump_data)
    parser.add_option("--dry-run", action="store_true")
    parser.add_option("--dump-data", action="store_true")
    parser.add_option("--search", action="store_const", dest="action",
                      const="search")
    opts, rest = parser.parse_args()

    config.load("config.py")

    config.dry_run = opts.dry_run
    config.dump_data = opts.dump_data

    if opts.action == "sync":
        initialize_logging()
        try:
            sync_ldap_servers()
        except SystemExit:
            raise
        except:
            logging.error("an unhandled exception occurred", exc_info=True)
    elif opts.action == "search":
        if len(rest) != 2:
            print >>sys.stderr, "search needs two parameters:"
            print >>sys.stderr, "servertype ('kolab' or 'ad') "\
                  "and search ('user' or 'contact')"
            sys.exit(1)
        search(server_type = rest[0], search_type = rest[1])
    else:
        print >>sys.stderr, "unknown action %r" % opts.action


if __name__ == "__main__":
    main()

--- NEW FILE: transferrer.py ---
# Copyright (C) 2006 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh at intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with the software for details.

"""Transfer ldap information from one ldap server to another"""

import logging
import sets

import ldap
import ldap.modlist

import converter


class Search:

    """LDAP Search definition

    A search definition has the following public instance variables:

    base_dn -- the base dn for the search.  This should be either a
        string or another Search instance.  In the latter case that
        dn-search is used to determine a list of dns under which to
        perform the search.

    filterstr -- The LDAP filter string

    search_scope -- the search scope.  Must be one of the following
        constants from the ldap module: SCOPE_SUBTREE, SCOPE_ONELEVEL
        oder SCOPE_BASE.  It can also be the string 'OU_SUBTREES' in
        which case the subtrees defined by all objects of type
        organizatinalUnit immeditely below base_dn will be searched
        individually to avoid transferring too many results in one
        search.

    attrlist -- A list of attribute names to return from the search.
        This is optional.  How this is interpreted depends on how the
        search is used by the converter.  See Transferrer for more
        details.
    """

    def __init__(self, base_dn, filterstr, search_scope, attrlist=None):
        self.base_dn = base_dn
        self.filterstr = filterstr
        self.search_scope = search_scope
        self.attrlist = attrlist


class LDAPServer:

    """LDAP Server Definition

    An LDAP server definition has the following public instance
    variables:

    uri -- The serve uri as a string.  Should be just the protocol (ldap
        or ldaps) and the host name.  E.g. 'ldap://my.ldap.server'

    bind_dn, bind_pw -- The dn and password for the ldap bind.  The user
        given here should have read and write permissions in the
        appropriate sub-trees.

    user_search -- A search definition (Search instance) for a search
        for user accounts.

    contact_search -- A search definition for a search for contact
        entries
    """

    def __init__(self, uri, bind_dn, bind_pw, user_search, contact_search):
        self.uri = uri
        self.bind_dn = bind_dn
        self.bind_pw = bind_pw
        self.user_search = user_search
        self.contact_search = contact_search

    def search(self, search, attrlist=None):
        """Searches and returns the results as a list of (dn, record) pairs.

        The search parameter should be one of the server's search
        attributes.  The attrlist parameter is optional and when not
        given, the attrlist of the search definition is used.
        """
        logging.debug("reading from %r", self.uri)
        conn = ldap.initialize(self.uri)
        conn.simple_bind_s(self.bind_dn, self.bind_pw)
        return self._search_conn(conn, search, attrlist=attrlist)

    def _search_conn(self, conn, search, attrlist=None):
        """perform search on conn"""
        if isinstance(search.base_dn, Search):
            dns = [dn for dn, record in self._search_conn(conn, search.base_dn)]
        else:
            dns = [search.base_dn]
        if attrlist is None:
            attrlist = search.attrlist

        result = []
        for dn in dns:
            logging.debug("searching dn %r", dn)
            result += [item
                       for item in conn.search_s(dn, search.search_scope,
                                                 filterstr=search.filterstr,
                                                 attrlist=attrlist)
                       if item[0] is not None]
        return result


class Transferrer:

    """Main class to transfer ldap data from one serve to another.

    This class reads ldap data from a source server, transforms the data
    and writes it to a destination servers.  Both servers are described
    by instances of the LDAPServer class.  The search on the source
    server is defined by the source server's user_search attribute.  The
    search on the destination serve is defined by the contact_search
    attribute.  The transformation is described by an EntryConverter
    instance.
    """

    def __init__(self, source, destination, converter, dry_run=False,
                 dump_data=False):
        """Initializes the transferrer

        Parameters:
        source -- The source ldap server definition
        destination -- the destinatin ldap server definition
        converter -- the EntryConverter instance to use when converting
            an ldap entry.
        dry_run -- boolean.  If true, do not write to the destination
            server.  Defaults to False.
        """
        self.source = source
        self.destination = destination
        self.converter = converter
        self.dry_run = dry_run
        self.dump_data = dump_data

    def transfer(self):
        """Performs the data transfer"""
        source_data = self.read_ldap(self.source, self.source.user_search)
        if self.dump_data:
            import pprint
            print "************* Transferrer.transfer: source_data " + \
                  "before normalization"
            pprint.pprint(source_data)
        converter.normalize_dns(source_data)
        if self.dump_data:
            import pprint
            print "************* Transferrer.transfer: source_data"
            pprint.pprint(source_data)


        converted_data, attrlist = self.convert_data(source_data)
        converter.normalize_dns(converted_data)
        if self.dump_data:
            print "************* Transferrer.transfer: converted_data"
            pprint.pprint(converted_data)

        destination_data = self.read_ldap(self.destination,
                                          self.destination.contact_search,
                                          attrlist=attrlist)

        # called for all transfers, even if we only want to remove for
        # destination ad.
        # FIXME: This shouldn't be hard-wired.
        converter.remove_ad_extra_attributevalues(destination_data)

        converter.normalize_dns(destination_data)
        if self.dump_data:
            print "************* Transferrer.transfer: destination_data"
            pprint.pprint(destination_data)

        self.write_data(self.destination, destination_data,
                        converted_data)

    def read_ldap(self, server, search, attrlist=None):
        """Reads from the server using the search definition search.
        The attrlist parameter is optional and when not given, the
        attrlist of the search definition is used.
        """
        return dict(server.search(search, attrlist=attrlist))

    def convert_data(self, entries):
        """Converts the entries using the entry converter.
        The entries dictionary is modified in place.
        """
        converted = {}
        attributes = sets.Set()
        for dn, attrs in entries.iteritems():
            added_entries = []
            converted_entry = self.converter.convert(dn, attrs, added_entries)
            if converted_entry is not None:
                for entry in [converted_entry] + added_entries:
                    new_dn, new_attrs = entry
                    converted[new_dn] = new_attrs
                    attributes.union_update(new_attrs)
        return converted, list(attributes)


    def write_data(self, server, old_data, new_data):
        """Compares the data and updates the server.

        This method compares the new_data with the old_data and modifies
        the server accordingly.  Objects in new_data but not in old_data
        are created on the server.  Objects in old_data but not in
        new_data are removed from the server.  Objects that differ in
        new_data and old_data are modified on the server.  If dry_run
        has been specified, no actual changes are made.  This method
        logs its actions with a DEBUG log level.
        """
        old_set = sets.Set(old_data)
        new_set = sets.Set(new_data)
        create_set = new_set - old_set
        delete_set = old_set - new_set
        common_set = new_set & old_set

        logging.debug("writing to %r", server.uri)
        conn = ldap.initialize(server.uri)
        conn.simple_bind_s(server.bind_dn, server.bind_pw)

        for dn in delete_set:
            logging.debug("deleting contact %r", dn)
            self.call_with_error_log(conn, "delete_s", dn)

        for dn in create_set:
            logging.debug("creating contact %r", dn)
            self.call_with_error_log(conn, "add_s",
                                     dn, ldap.modlist.addModlist(new_data[dn]))
        for dn in common_set:
            modlist = ldap.modlist.modifyModlist(old_data[dn], new_data[dn])
            if modlist:
                logging.debug("updating contact %r: modlist=%r", dn, modlist)
                self.call_with_error_log(conn, "modify_s", dn, modlist)
            else:
                logging.debug("contact %r already up to date", dn)

    def call_with_error_log(self, conn, methodname, *args):
        """Call an ldap connection method and log errors that occur.
        If dry_run has been specified the method is not actually called.
        """
        if not self.dry_run:
            try:
                getattr(conn, methodname)(*args)
            except:
                logging.error("Error calling ldap method %r%r", methodname,
                              args, exc_info=True)





More information about the commits mailing list