#!/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()
#
#
#