#!/usr/bin/python # # l i s t S y n c h r o . p y # # # Created: October 9, 2005 by Simon Brown # # Script to update Mailman lists to match directory group membership. # # WHAT IT DOES: # # This script is useful if you want to have a mail list that mirrors the # membership of a matching open directory group. Or, you could also just use # it as a way to manage a mail list membership by using Workgroup Manager. # # HOW DOES IT WORK: # # The script starts by getting the name of every Mailman list. For every list # we then attempt to get the members of the matching open directory group. # If we find a matching group, we store all of the account names as email addresses # in a temporary file, and then call the Mailman routine sync_members to add or # remove anyone that is/isn't present in the open directory group. # # CONFIGURATION: # # There are a couple of variables that will need configuration before this script # can be used. # # - kDomain: must be set to the domain that should be added to the account names. # - kReportOnly: this is set to True by default. When ready to actually have the # script do the synchronization this should be changed to False. # - kEmailSynchronizationReport: True if the synchronization report should be emailed out. # - kReportMembership: True of a list membership report should be saved to a file. The # report location is determined by kReportMembershipPath. # - kReportMembershipPath: location to save optional report of list memberhsip. # - Add execute permissions for script w/ chmod u+x listSynchro.py or a similar cmd. # # Finally, kReportMsgPath must be set to the message template for sending out reports. # The template determines the message subject, its priority, and who will be receiving # the reports, so this file must be edited to contain the appropriate values. It can # optionally be set to an empty string to skip the reporting function. # HOW TO USE: # # After modifying where necessary, run the script via the terminal command line or # with a crontab entry. It would be best to run initialy with kReportOnly = True, # so you can check for problems before modifying the actual groups. The script must # be run as root b/c Mailman's sync_members command requires it. # # LIMITATIONS: # # The directory server must be running on the same system as the script # (although the dscl calls could be modified to access at a different location). # To email reports Postfix must be configured sufficiently to allow email to # be sent. Groups can only be nested one level deep. # # # Can only work with nested groups that are one level deep. # # MODIFICATION HISTORY: # # 05/10/01 Created the shell of program using piece from reply.py # dscl: extended class, adding get_groups & get_group_users # 05/10/12 sync_members: created procedure # stripped out some unneeded code from reply.py # 05/10/13 sync_members: now returns a report of what was done as a string. # 05/10/15 filtered out warning generated when using tmpnam # main: can now translate list names into a different group name. # get_group_users: fixed prob. w/ result when group is empty. # 05/10/16 get_group_users: can now handle nested groups (1 level only) # 05/10/17 dscl: calls to dscl now specify the full command path. # 06/04/16 make_email_addresses: now use formatted string instead of + # email_text: added subroutine from reply.py # Now only print list count & errors. Detailed report is now # emailed out instead. # 06/04/23 E-mailed synchronization reports are now optional. # membership_report: added, we can now save a list membership report to a file. # get_users & get_groups: now use a predefined string for dscl commands. # main: modified to user membership_report & changes to honor reporting flags. # # NOTES: # # - Handle nested groups deeper than 1 level import commands,os,string,sys import time, warnings # True to only perform a dry run of changes that would be made, False to actually # perform the list synchronization. kReportOnly = False # COMMAND PATHS kDsclPath = "/usr/bin/dscl" kList_listsPath = "/usr/share/mailman/bin/list_lists" kList_membersPath = "/usr/share/mailman/bin/list_members" kSync_membersPath = "/usr/share/mailman/bin/sync_members" kSendmailPath = "/usr/sbin/sendmail" # COMMAND STRINGS kDsclSearchCmd = kDsclPath+" localhost -search /LDAPv3/127.0.0.1/Groups GeneratedUID " kDsclReadCmd = kDsclPath+" localhost -read /LDAPv3/127.0.0.1/Groups/" kDsclListCmd = kDsclPath+" localhost -list /LDAPv3/127.0.0.1/Users" # REPORTS # Path to message text (headers only) when sending out report. # The path here is only used as an example. It could be placed anywhere with # the appropriate permissions. kReportSynchroPath = '/usr/local/sibr.listSynchro/reportMsg.txt' # Path to optionally save a copy of current list membership for all lists. kReportMembershipPath = '/Users/Shared/=Membership Report.txt' kEmailSynchronizationReport = True kReportMembership = True # # EXCEPTIONS & TRANSLATIONS # # The following dictionary is used to skip a list that you don't want to synchronize # with a group of the matching name, or to convert an equivalent list & group that # do not share the same name. kListTranslation = { 'admin':'administration' } # DOMAIN NAME kDomain = 'domainname.com' # This will filter out the warning that we would otherwise get about using # tmpnam. Since we are storing nothing but email addresses in the file and # the file should only be writeable by root this can be safely ignored. warnings.filterwarnings(action = 'ignore', \ message='tmpnam is a potential security risk.*', \ category=RuntimeWarning, \ module = '__main__') # # c l a s s d s c l # class dscl: def get_users (): """Returns a list all users known by Open Directory.""" # Must be run as root: if not, a user & password will be required. dsclResult,usersStr = commands.getstatusoutput (kDsclListCmd) # Return an empty list if dscl has an error. if dsclResult != 0: return None # Convert the string of line delimited data to a list. return usersStr.splitlines() get_users = staticmethod (get_users) # # g e t _ g r o u p s # def get_groups(): """Return a list of all defined groups on the local Open Directory server. Returns None if there was an error while reading in groups.""" dsclResult,usersStr = commands.getstatusoutput (kDsclPath+" localhost -list /LDAPv3/127.0.0.1/Groups") # Return an empty list if dscl has an error. if dsclResult != 0: return None # Convert the string of line delimited data to a list. return usersStr.splitlines() get_groups = staticmethod (get_groups) # # g e t _ g r o u p _ u s e r s # def get_group_users (inGroup): # Start out with the initial group. If there are nested groups, then we'll add to this list. groups = [inGroup] # Do we have any nested groups for this group? dsclResult,xs = commands.getstatusoutput (kDsclReadCmd + inGroup + ' NestedGroups') # Nested group data from dscl? if (dsclResult == 0) and (not xs.startswith ("No such key:")): # Split the dscl results into seperate items, skipping the label at 0. nestedGroupIDs = xs.split()[1:] for theGroupID in nestedGroupIDs: # Search for the group with the matching ID. dsclResult,xs = commands.getstatusoutput (kDsclSearchCmd + theGroupID) if dsclResult != 0: print "Error! dscl returned",dsclResult print xs return None # We only want the name of group returned by dscl, which is first word of result. # We'll add this to list of groups to get the users for. groups.append (xs[0:xs.index("\t")]) usersList = [] for theGroup in groups: dsclResult,xs = commands.getstatusoutput (kDsclReadCmd + theGroup + ' Member') if dsclResult != 0: if theGroup != inGroup: print "Error! dscl returned",dsclResult print xs return None # Was this an empty group? if not xs.startswith ("No such key:"): # Convert the string of space delimited data to a list. We skip the first item, # which is the group name. usersList += xs.split()[1:] return usersList get_group_users = staticmethod (get_group_users) # # end class dscl # # # e m a i l _ t e x t # def email_text (*inStrOrPath): """Sends an email using sendmail. The input is one or more parameters, which may either be a string or a file path. File path parameters must begin with the "/" character. Will throw an IOError if there was a problem reading in the file.""" def return_str_or_file (strOrPathItem): # If first char is forward slash then we'll treat the string as a file path. if strOrPathItem[0] == '/': # Attempt to read in the message header in the file we were given the path to. # open file w/ read-only access. f = open (strOrPathItem,'r') # Read in the text and close the file. strOrPathItem = f.read() f.close() # Make sure we terminate each string. if strOrPathItem [-1] != '\n': return strOrPathItem + "\n" else: return strOrPathItem # Open a connection to the sendmail command in the shell. # -i don't treat a . on a line as end of text # -t extract recipients from text of message. p = os.popen(kSendmailPath + ' -i -t', 'w') for strItem in inStrOrPath: # Read in file if necessary, then output string to the sendmail process. # If it was a file & there was an error reading it in we'll catch that here. try: x = p.write (return_str_or_file (strItem)) except IOError: print 'Error: could not write to the command', strItem return False # Close our connection to the sendmail/shell process. exitCode = p.close() return exitCode == False # # m e m b e r s h i p _ r e p o r t # def membership_report (inLists,inReportPath): """Saves a listing of every member of every mail list. Expects a list of mail list names, and file path to save the resulting report to.""" f = open (inReportPath,'w') print >> f, "Generated on " + time.strftime("%a, %d %b %Y", time.localtime()) print >> f, "" for maillist in inLists: print >> f, "***",maillist,"***" print >> f, commands.getoutput (kList_membersPath + " " + maillist) print >> f, "" f.close() # # g e t _ l i s t _ n a m e s # def get_list_names (): """Return a list containing the names of every Mailman list.""" # Get a bare list of all lists known by Mailman. allLists = commands.getoutput (kList_listsPath + " -b") return allLists.splitlines() # # m a k e _ e m a i l _ a d d r e s s e s # def make_email_addresses (accountsList,domainStr): """Convert the list of account names in accountsList to email addresses and return as a list.""" return [('%s@%s' % (account, domainStr)) for account in accountsList] #return [account+'@'+domainStr for account in accountsList] # # s y n c _ m e m b e r s # def sync_members (mailList, listMembers): """Utilizes the Mailman command sync_members to synchronize the list named in mailList to the list of email addresses in listMembers. Returns a report of what was done as a string.""" # Open a temp file for storing list members in. # (the mailman command sync_members requires these to be in a file) tmpName = os.tmpnam () try: f = open (tmpName,'w') except: tx = 'Error! Unable to open temporary file at '+tmpName+'\n' print tx, return tx try: # Write out every member's address to the file, terminating each member with a new line. f.write ('\n'.join (listMembers)) f.close() except: tx = 'Error! Unable to write out list members to temporary file\n' print tx, return tx if kReportOnly: # Call Mailman's sync_members command with path to temp. file containing email addresses. result,tx = commands.getstatusoutput (kSync_membersPath + ' -n -f ' + tmpName + ' ' + mailList) else: result,tx = commands.getstatusoutput (kSync_membersPath + ' -f ' + tmpName + ' ' + mailList) # Remove the temp. file now that it is no longer needed for storing the email address list. try: os.remove (tmpName) except: tx += 'Error! Unable to remove temp file\n' return tx # # m a i n # def main(): """Main program loop.""" tx = "Start: " + time.asctime() + '\n\n' print tx, # Collect report output here. reportList = [tx] # Get a list of all the mailman lists. mailmanLists = get_list_names() tx = str (len (mailmanLists)) + ' lists to process\n' print tx, reportList.append(tx) # Check every list individually for theMailList in mailmanLists: # In some special cases you may need to have a mail list with a different name # than the directory group name. This step will use the kListTranslation variable # as a lookup table to convert from the mail list name to the group name. try: # Lookup equivalent group name for mailList theGroup = kListTranslation [theMailList] except KeyError: # The list name & group name are the same. theGroup = theMailList # Attempt to get every member of the group in the directory that corresponds with the mailmman list. dirGroupMembers = dscl.get_group_users (theGroup) # Calc. what we should display as the number of members currently in system directory # for this group. if dirGroupMembers != None: memberCount = len (dirGroupMembers) else: memberCount = 0 # Print a header with the name of the list, the group name (if different than the list name), # and the number of users in the group. if theMailList == theGroup: tx = "\n*** %s: %s users ***\n" % (theMailList, memberCount) else: tx = "\n*** %s (%s): %s users ***\n" % (theMailList, theGroup, memberCount) reportList.append(tx) # Did we have a corresponding directory group? if dirGroupMembers != None: # Convert the account names to email addresses emailList = make_email_addresses (dirGroupMembers,kDomain) # Have mailman synchronize it's members list with the one we got from the system directory. reportList.append( sync_members (theMailList, emailList) + "\n" ) else: print "Warning! No matching directory group for " + theMailList reportList.append ("Warning!: No Matching directory group for " + theMailList + "\n") reportList.append ("\nSynchronization of lists complete\n") # Should we create a report listing every member of every list? if kReportMembership: # Generate report and save it to disk. membership_report (mailmanLists,kReportMembershipPath) reportList.append ("\nUpdated Membership Report\n") if kEmailSynchronizationReport: # email a report of what was done. We join the reportList b/c email_text is expecting # a string, not a list. email_text (kReportSynchroPath, ''.join (reportList)) else: for line in reportList: print line print print "Done:",time.asctime(),"\n" # # # main() # # #