#!/usr/bin/python ### ### Copyright (C) 1998-2010 by the Free Software Foundation, Inc. ### ### 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., 51 Franklin Street, Fifth Floor, Boston, MA ### 02110-1301, USA. http://www.fsf.org/licensing/licenses/gpl.html ### ### --------------------------- ### ### This script implements a basic Mailman administrative email ### interface since MM apparently lacks one. It's meant to be given ### an email as STDIN with the commands in the body of the message, ### one comand per line, and one reply will be created for each ### line/command. A procmail example of forwarding a message to this ### script would be the following: ### ### :0 H ### * ^Subject: mailman_admin somerandompass123 ### | $HOME/mailman/mailman_admin_cmd_handler ### ### Note that the subject line there is an optional extra security ### measure; it is not necessary in an environment where we can only ### "pipe to a program" without doing additional header checks. ### ### If installing this to a new server, search for INSTALL comments. ### ### Author: Anthony R. Thompson ### Contact: put @ between art and sigilservices.com ### Created: Dec 2009 ### import os, sys, re, smtplib, getopt, email, socket from cStringIO import StringIO # needed later for faster string append # See http://www.skymind.com/~ocrow/python_string/ for more info from email.Utils import parseaddr MM_ROOT = '/usr/lib/mailman/' # To access Mailman modules; INSTALL sys.path.append(MM_ROOT) from Mailman import Errors from Mailman import MailList from Mailman import Utils from Mailman import mm_cfg sys.path.append(os.path.dirname(os.path.abspath(sys.argv[0]))) # cur dir import ListStream # our custom obj for saving stdout/stderr; INSTALL # Need to symlink these programs from Mailman bin dir into our # installation dir, adding .py extensions - but still need to append # Mailman bin dir to path otherwise we'll get import errors. For ex: # ln -s /usr/lib/mailman/bin/list_members ./list_members.py # ln -s /usr/lib/mailman/bin/clone_member ./clone_member.py MM_BIN = MM_ROOT + 'bin/' sys.path.append(MM_BIN) import list_members # a Mailman bin program; NEEDS SYMLINK (INSTALL) import clone_member # a Mailman bin program; NEEDS SYMLINK (INSTALL) # No symlink required for this, it's more module-like than bin scripts sys.path.append(MM_ROOT + 'Mailman/Commands/') import cmd_set # Default sender/from for any replies/messages we send - better to # hardcode this here than to get it from the To header, because that # can have multiple values. Must set up this address to fwd here. DEFAULT_FROM = 'mailman-admin-cmd@' + socket.gethostname() # INSTALL SMTP_SERVER = 'localhost' # INSTALL SUBJECT_PASSWD = 'SUBJECTPASSWDGOESHERE' # INSTALL # Used in some mailman library functions such as ApprovedDeleteMember SCRIPT_NAME = os.path.realpath(sys.argv[0]) # Make a copy of the original sys.argv array because we need to modify # it later to call external command line Python scripts without going # through the shell (for security), and restoring it is good practice __argv__ = sys.argv[:] # [:] is array copy trick # Combination of valid commands and "usage" (options) info; after # adding a command here, you need to add a handler function below. # Trying to make these commands as similar to user email cmds as poss. CMD_USAGE_DICT = { # commands that support the "all" option for list name 'unsubscribe': ['listname|all', 'passwd', 'useremail'], 'change': ['listname|all', 'passwd', 'oldaddr', 'newaddr'], 'password': ['listname|all', 'passwd', 'useremail', 'newpass'], 'set': ['listname|all', 'passwd', 'useremail', 'optionname', 'optionval'], # commands which only support one list at a time 'who': ['listname', 'passwd'], # "all" is NOT supported 'show': ['listname', 'passwd', 'useremail'], 'subscribe': ['listname', 'passwd', 'useremail', 'digest|nodigest', 'Full Name'], # full name IS required for subscriptions 'help': [], 'end': [], 'no_handler_test': [] # NO handler defined; for unit testing } VALID_CMDS = CMD_USAGE_DICT.keys() ALL_OPTION_CMDS = ['unsubscribe', 'change', 'password', 'set'] def cmd_usage(cmd): return ' '.join([cmd, ' '.join(CMD_USAGE_DICT[cmd])]) # Various simple utility/helper functions ALL_MAILING_LISTS = Utils.list_names() def is_list(listname): return (listname in ALL_MAILING_LISTS) def is_list_owner(mlist, owner_email): return (owner_email in mlist.owner) def valid_list_pass(mlist, passwd): # note: Utils.sha.new changed to Utils.sha_new return (mlist.password == Utils.sha_new(passwd).hexdigest()) def valid_site_admin_pass(sapass): # note: Utils.sha.new changed to Utils.sha_new return (Utils.sha_new(sapass).hexdigest() == Utils.get_global_password(True)) def valid_user_pass(mlist, useremail, owner_pass): if (not mlist.isMember(useremail)): return False else: return (owner_pass == mlist.getMemberPassword(useremail)) def get_list_unlocked(listname): return MailList.MailList(listname, lock=0) # Convert items in a list to one big string, efficiently; note that # this assumes callers will put \n on each item if they want newlines def list_to_string(list): file_str = StringIO() for item in list: file_str.write(str(item)) return file_str.getvalue() # A general "send email message" function, basic stuff def send_mail(msgbody, subject, toaddrs, fromaddr): # if msgbody is a list, convert to a string (efficently) if (type(msgbody) is list): msgbody = list_to_string(msgbody) # replace it smtp_server = smtplib.SMTP(SMTP_SERVER) msghead = 'From: ' + fromaddr + "\n" msghead += 'To: ' + toaddrs + "\n" msghead += 'Subject: ' + subject + "\n" message = msghead + "\n" + msgbody # smtplib will throw its own exceptions if there are problems smtp_server.sendmail(fromaddr, toaddrs, message) ### ### Methods for saving and restoring the stderr and stdout streams; ### we need these because we sometimes call command line programs ### def save_output(): outstream = ListStream.ListStream() sys.stdout = outstream return outstream def restore_output(outstream): sys.stdout = sys.__stdout__ output = outstream.contents() outstream.clear() return output def save_error(): errstream = ListStream.ListStream() sys.stderr = errstream return errstream def restore_error(errstream): sys.stderr = sys.__stderr__ error = errstream.contents() errstream.clear() return error ### ### Command handlers here, main message processing loop below at end ### Note that the command is still first item in each "arg" param ### # Return usage info (valid options) for all valid commands def help(): res = ["Valid commands:\n\n"] for cmd in VALID_CMDS: res.append(cmd_usage(cmd) + "\n") res.remove("no_handler_test \n") # for unit testing, hide from world return list_to_string(res) # who listname passwd - already checked listname and passwd in main() def who(mlist, args): listname, passwd = args[1:3] sys.argv = [MM_BIN + 'list_members', '-r', '-d', '-f', '-p', listname] outstream = save_output() errstream = save_error() try: list_members.main() except SystemExit: pass # in cmd line testing, calling main() exited lmout = restore_output(outstream) lmerr = restore_error(errstream) sys.argv = __argv__ # good practice is to restore original sys.argv if (len(lmerr) > 0): return list_to_string(lmerr) else: return list_to_string(lmout) # password listname passwd useremail newuserpass # Already checked listname and passwd in main(), still need to check # whether user is subscribed to list; not checking old user pass bc # this is run by list admin - user authenticated somewhere else, # generating this request sent by list admin account. def password(mlist, args): listname, passwd, useremail, userpass = args[1:5] if (not mlist.isMember(useremail)): return "No such subscriber: %s on list: %s\n" % (useremail, listname) else: try: mlist.Lock() mlist.setMemberPassword(useremail, userpass) mlist.Save() finally: mlist.Unlock() return "Password updated for: %s on list: %s\n" % (useremail, listname) # subscribe listname passwd digest|nodigest user@place [Full Name] # Does NOT support "all" option; must subscribe to ONE list with this. # Checked listname/passwd in main(), need to check if user is on list def subscribe(mlist, args): cmd, listname, passwd, useremail, digestpref = args[0:5] fullname = ' '.join(args[5:]) if (digestpref not in ['digest', 'nodigest']): return "Usage: %s\n" % (cmd_usage(cmd)) # Check to see if they're already subscribed if (mlist.isMember(useremail)): return "Already a subscriber: %s\n" % (useremail) # Now do the adding - this comes from the add_member mm/bin script class UserDesc: pass userdesc = UserDesc() userdesc.address = useremail userdesc.fullname = fullname userdesc.digest = (digestpref == 'digest') try: try: mlist.Lock() mlist.ApprovedAddMember(userdesc, None, 0) mlist.Save() except Errors.MembershipIsBanned, pattern: return "Banned address - matched %s\n" % (pattern) except Errors.MMBadEmailError: if userdesc.address == '': return "Bad/Invalid email address: blank line\n" else: return "Bad/Invalid email address: %s\n" % (useremail) except Errors.MMHostileAddress: return "Hostile address (illegal characters): %s\n" % (str(useremail)) finally: mlist.Unlock() return "Subscribed %s as %s\n" % (useremail, fullname) # unsubscribe listname|all passwd useremail # Checked listname/passwd in main(), need to see if they're on list. def unsubscribe(mlist, args): listname, passwd, useremail = args[1:4] if (not mlist.isMember(useremail)): return "No such subscriber: %s\n" % (useremail) try: # This code comes from remove_member bin script mlist.Lock() # first False means no notice to admin (no admin_notification) # second False means no notice to user (no userack) mlist.ApprovedDeleteMember(useremail, SCRIPT_NAME, False, False) mlist.Save() finally: mlist.Unlock() return "User: %s removed from list: %s\n" % (useremail, listname) # We use "change" instead of "clone" for similarity to Listserv # command of same name, and because we're always going to use the # "remove" old address option so it's more like changing, anyway. But # really, we're just calling the clone_member mailman bin script. # change listname|all passwd olduseremail newuseremail # Already checked listname and passwd in main(). def change(mlist, args): listname, passwd, oldaddr, newaddr = args[1:5] if (not mlist.isMember(oldaddr)): return "No such subscriber: %s\n" % (oldaddr) # Do the change - call the clone_member mailman bin script sys.argv = [MM_BIN +'clone_member', '-l', listname, '-r', oldaddr, newaddr] outstream = save_output() errstream = save_error() try: clone_member.main() except SystemExit: pass # in cmd line testing, calling main() exited lmout = restore_output(outstream) lmerr = restore_error(errstream) sys.argv = __argv__ # good practice is to restore original sys.argv if (len(lmerr) > 0): return list_to_string(lmerr) else: return list_to_string(lmout) # set listname passwd useremail optname optval1 ... # Note that these options are based on OUR list preferences (INSTALL); # to support other set options you'll need to add branches down below. def set(mlist, args): listname, passwd, useremail, optname, optval = args[1:6] valid_options = ['digest', 'delivery', 'mod', 'authenticate', 'show'] if (not mlist.isMember(useremail)): return "No such subscriber: %s\n" % (useremail) elif (optname == 'authenticate'): return "set authenticate not supported; a password is " + \ "required with each command\n" elif (optname == 'show'): return "set show not supported; use show listname passwd useremail\n" elif (not optname in valid_options): return "set options supported: digest, delivery, mod\n" # We always want: # ack off (never send out special "your post was received" emails) # myposts on (always get your own posts to the list back) aka not metoo # hide off (never allow people to hide their addresses) # duplicates on (send out on list even if mentioned in To line) # reminders off (never send out a password reminder for each list) # MM = Listserv option equivalents: # digest on|off = digest/nodigest # delivery on|off = mail/nomail # myposts on|off = repro/rorepro # mod on|off = review=noreview # This is kind of a hack for the cmd_set.py module, oh well class Res: pass response = Res() # Note that setting private variables here is a bit of a hack, due # to the fact that Mailman doesn't check for LIST passwords, only # list admin passwords, with these options. But since we actually # do check the password in main(), this really isn't too bad. # set_cmds.set_authenticate(response, [list_admin_pass, # 'address=' + list_admin]) # for future ref, if needed set_cmds = cmd_set.SetCommands() set_cmds._SetCommands__authok = 1 set_cmds._SetCommands__address = useremail response.mlist = mlist response.returnaddr = '' response.results = [] if (optname == 'digest'): if (not optval in ['plain', 'mime', 'on', 'off']): return "Usage: set listname passwd useremail digest " + \ "plain|mime|off\n" if (optval == 'on'): optval = 'plain' # unofficial support for "on" try: mlist.Lock() set_cmds.set_digest(response, [optval]) mlist.Save() return list_to_string(response.results) + \ " %s for: %s on list: %s\n" % (optval, useremail, listname) finally: mlist.Unlock() elif (optname == 'delivery'): if (not optval in ['on', 'off']): return "Usage: set listname passwd useremail delivery on|off\n" try: mlist.Lock() set_cmds.set_delivery(response, [optval]) mlist.Save() return list_to_string(response.results) + \ " %s for: %s on list: %s\n" % (optval, useremail, listname) finally: mlist.Unlock() elif (optname == 'mod'): if (not optval in ['on', 'off']): return "Usage: set listname passwd useremail mod on|off\n" try: mlist.Lock() mlist.setMemberOption(useremail, mm_cfg.Moderate, optval) # To check this: mlist.getMemberOption(addr, mm_cfg.Moderate) mlist.Save() return "mod option set %s for: %s on list: %s\n" % \ (optval, useremail, listname) finally: mlist.Unlock() def show(mlist, args): listname, passwd, useremail = args[1:4] if (not mlist.isMember(useremail)): return "No such subscriber: %s\n" % (useremail) # This is kind of a hack for the cmd_set.py module, oh well class Res: pass response = Res() # See set method for notes on setting private variables here set_cmds = cmd_set.SetCommands() set_cmds._SetCommands__authok = 1 set_cmds._SetCommands__address = useremail response.mlist = mlist response.returnaddr = '' response.results = [] set_cmds.set_show(response, ['address=' + useremail]) return re.sub('Your current option settings', 'Current option settings for ' + useremail, list_to_string(response.results)) def usage(): print 'Usage: echo "Test STDIN Input" | ' + sys.argv[0] + \ ' -s "Subject line" -f "from@someplace.org" -t "recip@other.org"' ### ### Main processing loop - we return a result for unit testing purposes ### def main(): # Use standard email module to get sender and body msg = email.message_from_string(list_to_string(sys.stdin.readlines())) mail_body = re.split('\n', msg.get_payload()) from_name, mail_from = parseaddr(msg.get('From')) # If a subject password was set above, do security check on that if (SUBJECT_PASSWD <> ''): subj = msg.get('Subject') if (not re.search(SUBJECT_PASSWD, subj)): send_mail('Required password not present in subject line.', 'Re: ' + subj, mail_from, DEFAULT_FROM) sys.exit(0) # Collect all responses to return at end, for testing/debugging all_res = [] # Process each command, one per line, reply for each line/command for line in mail_body: # Clear out the response from last command/line res = [] # Get rid of line ending line = re.sub('\n$', '', line) # Skip blank-ish lines if (not re.search('\w', line)): continue # Eliminate any extra space(s) at the beginning and end line = re.sub('^\s*', '', line) line = re.sub('\s*$', '', line) # Get the command for this line, everything before a space/newline cmd = re.sub('\s+.*', '', line) # Default reply subject - might not have a list name subj = "Re: %s (mailman_admin_cmd_handler)" % (cmd) # Also parse args here so funcs don't have to; note that the first # arg is still the command name (like sys.argv[0]) args = re.split('\s+', line) # is it a valid command? # does it provide the necessary number of arguments? # is there a handler defined for it? # is it the "end" command? # is it the "help" comand? # ok, actually run the command then # (if any errors, stop processing) # Invalid command? if (not cmd in VALID_CMDS): res.append("Unknown command: %s\n" % (cmd)) # Valid command, but invalid parameters? elif (len(args) < (len(CMD_USAGE_DICT[cmd]) + 1)): res.append("Usage: %s\n" % (cmd_usage(cmd))) # Valid command, which is "end" - no action, end processing # Previous command results have already been emailed & returned # This is optional and only present for email signatures, etc. elif (cmd == 'end'): break # Valid command, which is "help" - special case bc it has no args elif (cmd == 'help'): res.append(help()) # Valid command, but no defined handler - should never get this elif (not globals().has_key(cmd)): res.append("Undefined handler for valid command: %s\n" % (cmd)) # Valid command, has a handler, almost ready to run cmd - but # now check whether we're dealing with a valid list # name. Because this is neither "help" nor "end", we know that # the first param will be command, and second will be list name elif ( (args[1] <> 'all') and (not is_list(args[1])) ): res.append("No such list: %s\n" % (args[1])) elif ( (args[1] == 'all') and (cmd not in ALL_OPTION_CMDS) ): res.append("all option not supported for command: %s\n" % (cmd)) # OK, made it this far, so we know we have a valid command # that is neither "help" nor "end", and that the command has a # handler defined for it. We also know that we were given # either "all" or a valid list name for the list name # parameter. Now we can check the list password against the # given list (or all lists), and if that works, we can # instantiate the list and pass it (along with the arguments) # to the command handler function. else: # First figure out how many lists we're dealing with listname, passwd = args[1:3] if (listname == 'all'): possible_lists = ALL_MAILING_LISTS else: possible_lists = [listname] # valid, we checked above # Can be more specific with reply subject now, anyway subj = "Re: %s %s (mailman_admin_cmd_handler)" % (cmd, listname) # Now check each list against the list password; for those # that match, add the list object to those we'll process later lists_to_process = [] # will be a list of OBJECTS, not names # If given the magical site admin password, we'll let this # command be sent from (and replied to) any address. # Otherwise, we'll only allow commands to be sent from # (and replied to) a list owner. If that's the case, then # the password must either be their list owner password, # or the password for the list itself. for list in possible_lists: # First check if we have a valid list name try: mlist = get_list_unlocked(list) except Errors.MMUnknownListError: res.append("Unknown list: %s" % (list)) continue # Now check if we got the magic administrator password if (valid_site_admin_pass(passwd)): lists_to_process.append(mlist) # add list OBJECT # Not admin password, so check if it's list password or pass # for the list owner - but only if they're also a subscriber elif (is_list_owner(mlist, mail_from)): if (valid_list_pass(mlist, passwd) or valid_user_pass(mlist, mail_from, passwd)): lists_to_process.append(mlist) # add list OBJECT else: if (mlist.isMember(mail_from)): res.append("Bad password: %s for list: %s - you " \ % (passwd, list) + "must provide either the " + "list password or your subscriber password\n") else: res.append("Bad password: %s for list: %s - you " \ % (passwd, list) + "must provide either the " + "list password or be subscribed to the list " + "and provide your subscriber password (you " + "are not currently subscribed to this list)\n") else: res.append("Unauthorized: %s for list: %s - you are " \ % (mail_from, list) + "not an owner of the list " + "and did not provide the site administrator " + "password\n") # OK, finally ready to call the handler for this list, # since we know it's a valid command, with a handler, and # we know that given list password matches given list(s) for mlist in lists_to_process: args[1] = mlist.real_name # pretend command given on this list cmdfunc = globals()[cmd] # e.g., get "subscribe" func res.append(cmdfunc(mlist, args)) # e.g., actually CALL the func # If listname for this command was all, clean up response output if (listname == 'all'): tmpres = [] for line in res: if (re.search('^No such subscriber:', line)): continue else: tmpres.append(line) res = tmpres # Indented to email and return the response built up for THIS LINE send_mail(res, subj, mail_from, DEFAULT_FROM) # reply to sender for res_line in res: all_res.append(res_line) return all_res # so that testing has a result to check if __name__ == '__main__': print Utils.list_names() main()