1# Copyright (C) 2001-2018 by the Free Software Foundation, Inc.
2#
3# This program is free software; you can redistribute it and/or
4# modify it under the terms of the GNU General Public License
5# as published by the Free Software Foundation; either version 2
6# of the License, or (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
16# USA.
17
18"""Creation/deletion hooks for the Postfix MTA."""
19
20import os
21import pwd
22import grp
23import time
24import errno
25from stat import *
26
27from Mailman import mm_cfg
28from Mailman import Utils
29from Mailman import LockFile
30from Mailman.i18n import C_
31from Mailman.MailList import MailList
32from Mailman.MTA.Utils import makealiases
33from Mailman.Logging.Syslog import syslog
34
35LOCKFILE = os.path.join(mm_cfg.LOCK_DIR, 'creator')
36ALIASFILE = os.path.join(mm_cfg.DATA_DIR, 'aliases')
37VIRTFILE = os.path.join(mm_cfg.DATA_DIR, 'virtual-mailman')
38# Desired mode for aliases(.db) and virtual-mailman(.db) for both creation
39# and check_perms.
40targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH
41
42try:
43    True, False
44except NameError:
45    True = 1
46    False = 0
47
48
49
50def _update_maps():
51    # Helper function to fix owner and mode.
52    def fixom(file):
53        # It's not necessary for the non-db file to be S_IROTH, but for
54        # simplicity and compatibility with check_perms, we set it.
55        stat = os.stat(file)
56        if (stat[ST_MODE] & targetmode) <> targetmode:
57            os.chmod(file, stat[ST_MODE] | targetmode)
58        dbfile = file + '.db'
59        try:
60            stat = os.stat(dbfile)
61        except OSError, e:
62            if e.errno <> errno.ENOENT:
63                raise
64            return
65        if (stat[ST_MODE] & targetmode) <> targetmode:
66            os.chmod(dbfile, stat[ST_MODE] | targetmode)
67        user = mm_cfg.MAILMAN_USER
68        if stat[ST_UID] != pwd.getpwnam(user)[2]:
69            uid = pwd.getpwnam(user)[2]
70            gid = grp.getgrnam(mm_cfg.MAILMAN_GROUP)[2]
71            os.chown(dbfile, uid, gid)
72    msg = 'command failed: %s (status: %s, %s)'
73    acmd = mm_cfg.POSTFIX_ALIAS_CMD + ' ' + ALIASFILE
74    status = (os.system(acmd) >> 8) & 0xff
75    if status:
76        errstr = os.strerror(status)
77        syslog('error', msg, acmd, status, errstr)
78        raise RuntimeError, msg % (acmd, status, errstr)
79    # Fix owner and mode of .db if needed.
80    fixom(ALIASFILE)
81    if os.path.exists(VIRTFILE):
82        vcmd = mm_cfg.POSTFIX_MAP_CMD + ' ' + VIRTFILE
83        status = (os.system(vcmd) >> 8) & 0xff
84        if status:
85            errstr = os.strerror(status)
86            syslog('error', msg, vcmd, status, errstr)
87            raise RuntimeError, msg % (vcmd, status, errstr)
88        # Fix owner and mode of .db if needed.
89        fixom(VIRTFILE)
90
91
92
93def makelock():
94    return LockFile.LockFile(LOCKFILE)
95
96
97def _zapfile(filename):
98    # Truncate the file w/o messing with the file permissions, but only if it
99    # already exists.
100    if os.path.exists(filename):
101        fp = open(filename, 'w')
102        fp.close()
103
104
105def clear():
106    _zapfile(ALIASFILE)
107    _zapfile(VIRTFILE)
108
109
110
111def _addlist(mlist, fp):
112    # Set up the mailman-loop address
113    loopaddr = Utils.ParseEmail(Utils.get_site_email(extra='loop'))[0]
114    loopmbox = os.path.join(mm_cfg.DATA_DIR, 'owner-bounces.mbox')
115    # Seek to the end of the text file, but if it's empty write the standard
116    # disclaimer, and the loop catch address.
117    fp.seek(0, 2)
118    if not fp.tell():
119        print >> fp, """\
120# This file is generated by Mailman, and is kept in sync with the
121# binary hash file aliases.db.  YOU SHOULD NOT MANUALLY EDIT THIS FILE
122# unless you know what you're doing, and can keep the two files properly
123# in sync.  If you screw it up, you're on your own.
124"""
125        print >> fp, '# The ultimate loop stopper address'
126        print >> fp, '%s: %s' % (loopaddr, loopmbox)
127        print >> fp
128    # Bootstrapping.  bin/genaliases must be run before any lists are created,
129    # but if no lists exist yet then mlist is None.  The whole point of the
130    # exercise is to get the minimal aliases.db file into existance.
131    if mlist is None:
132        return
133    listname = mlist.internal_name()
134    fieldsz = len(listname) + len('-unsubscribe')
135    # The text file entries get a little extra info
136    print >> fp, '# STANZA START:', listname
137    print >> fp, '# CREATED:', time.ctime(time.time())
138    # Now add all the standard alias entries
139    for k, v in makealiases(listname):
140        # Format the text file nicely
141        print >> fp, k + ':', ((fieldsz - len(k)) * ' ') + v
142    # Finish the text file stanza
143    print >> fp, '# STANZA END:', listname
144    print >> fp
145
146
147
148def _isvirtual(mlist):
149    return (mlist and mlist.host_name.lower() in
150            [d.lower() for d in mm_cfg.POSTFIX_STYLE_VIRTUAL_DOMAINS])
151
152def _addvirtual(mlist, fp):
153    listname = mlist.internal_name()
154    fieldsz = len(listname) + len('-unsubscribe')
155    hostname = mlist.host_name
156    # Set up the mailman-loop address
157    loopaddr = Utils.get_site_email(mlist.host_name, extra='loop')
158    loopdest = Utils.ParseEmail(loopaddr)[0]
159    # And the site list posting address.
160    siteaddr = Utils.get_site_email(mlist.host_name)
161    sitedest = Utils.ParseEmail(siteaddr)[0]
162    # And the site list -owner, -bounces and -request addresses.
163    siteowneraddr = Utils.get_site_email(mlist.host_name, extra='owner')
164    siteownerdest = Utils.ParseEmail(siteowneraddr)[0]
165    sitebouncesaddr = Utils.get_site_email(mlist.host_name, extra='bounces')
166    sitebouncesdest = Utils.ParseEmail(sitebouncesaddr)[0]
167    siterequestaddr = Utils.get_site_email(mlist.host_name, extra='request')
168    siterequestdest = Utils.ParseEmail(siterequestaddr)[0]
169    if mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN:
170        loopdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
171        sitedest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
172        siteownerdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
173        sitebouncesdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
174        siterequestdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
175    # If the site list's host_name is a virtual domain, adding the list and
176    # owner addresses to the SITE ADDRESSES will duplicate the entries in the
177    # stanza for the list.  Postfix doesn't like dups so we try to comment them
178    # here, but only for the actual site list domain.
179    if (MailList(mm_cfg.MAILMAN_SITE_LIST, lock=False).host_name.lower() ==
180            hostname.lower()):
181        siteaddr = '#' + siteaddr
182        siteowneraddr = '#' + siteowneraddr
183        sitebouncesaddr = '#' + sitebouncesaddr
184        siterequestaddr = '#' + siterequestaddr
185    # Seek to the end of the text file, but if it's empty write the standard
186    # disclaimer, and the loop catch address and site address.
187    fp.seek(0, 2)
188    if not fp.tell():
189        print >> fp, """\
190# This file is generated by Mailman, and is kept in sync with the binary hash
191# file virtual-mailman.db.  YOU SHOULD NOT MANUALLY EDIT THIS FILE unless you
192# know what you're doing, and can keep the two files properly in sync.  If you
193# screw it up, you're on your own.
194#
195# Note that you should already have this virtual domain set up properly in
196# your Postfix installation.  See README.POSTFIX for details.
197
198# LOOP ADDRESSES START
199%s\t%s
200# LOOP ADDRESSES END
201
202# We also add the site list address in each virtual domain as that address
203# is exposed on admin and listinfo overviews, and we add the site list-owner,
204# -bounces and -request addresses as they are exposed in the list created
205# and/or password reminder email notices.
206
207# SITE ADDRESSES START
208%s\t%s
209%s\t%s
210%s\t%s
211%s\t%s
212# SITE ADDRESSES END
213""" % (loopaddr, loopdest, siteaddr, sitedest, siteowneraddr, siteownerdest,
214       sitebouncesaddr, sitebouncesdest, siterequestaddr, siterequestdest)
215    # The text file entries get a little extra info
216    print >> fp, '# STANZA START:', listname
217    print >> fp, '# CREATED:', time.ctime(time.time())
218    # Now add all the standard alias entries
219    for k, v in makealiases(listname):
220        fqdnaddr = '%s@%s' % (k, hostname)
221        if mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN:
222            localaddr = '%s@%s' % (k, mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN)
223        else:
224            localaddr = k
225        # Format the text file nicely
226        print >> fp, fqdnaddr, ((fieldsz - len(k)) * ' '), localaddr
227    # Finish the text file stanza
228    print >> fp, '# STANZA END:', listname
229    print >> fp
230
231
232
233# Blech.
234def _check_for_virtual_loopaddr(mlist, filename):
235    loopaddr = Utils.get_site_email(mlist.host_name, extra='loop')
236    loopdest = Utils.ParseEmail(loopaddr)[0]
237    siteaddr = Utils.get_site_email(mlist.host_name)
238    sitedest = Utils.ParseEmail(siteaddr)[0]
239    siteowneraddr = Utils.get_site_email(mlist.host_name, extra='owner')
240    siteownerdest = Utils.ParseEmail(siteowneraddr)[0]
241    sitebouncesaddr = Utils.get_site_email(mlist.host_name, extra='bounces')
242    sitebouncesdest = Utils.ParseEmail(sitebouncesaddr)[0]
243    siterequestaddr = Utils.get_site_email(mlist.host_name, extra='request')
244    siterequestdest = Utils.ParseEmail(siterequestaddr)[0]
245    if mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN:
246        loopdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
247        sitedest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
248        siteownerdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
249        sitebouncesdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
250        siterequestdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
251    # If the site list's host_name is a virtual domain, adding the list and
252    # owner addresses to the SITE ADDRESSES will duplicate the entries in the
253    # stanza for the list.  Postfix doesn't like dups so we try to comment them
254    # here, but only for the actual site list domain.
255    if (MailList(mm_cfg.MAILMAN_SITE_LIST, lock=False).host_name.lower() ==
256            mlist.host_name.lower()):
257        siteaddr = '#' + siteaddr
258        siteowneraddr = '#' + siteowneraddr
259        sitebouncesaddr = '#' + sitebouncesaddr
260        siterequestaddr = '#' + siterequestaddr
261    infp = open(filename)
262    omask = os.umask(007)
263    try:
264        outfp = open(filename + '.tmp', 'w')
265    finally:
266        os.umask(omask)
267    try:
268        # Find the start of the loop address block
269        while True:
270            line = infp.readline()
271            if not line:
272                break
273            outfp.write(line)
274            if line.startswith('# LOOP ADDRESSES START'):
275                break
276        # Now see if our domain has already been written
277        while True:
278            line = infp.readline()
279            if not line:
280                break
281            if line.startswith('# LOOP ADDRESSES END'):
282                # It hasn't
283                print >> outfp, '%s\t%s' % (loopaddr, loopdest)
284                outfp.write(line)
285                break
286            elif line.startswith(loopaddr):
287                # We just found it
288                outfp.write(line)
289                break
290            else:
291                # This isn't our loop address, so spit it out and continue
292                outfp.write(line)
293        # Now do it all again for the site list address. It must follow the
294        # loop addresses.
295        while True:
296            line = infp.readline()
297            if not line:
298                break
299            outfp.write(line)
300            if line.startswith('# SITE ADDRESSES START'):
301                break
302        # Now see if our domain has already been written
303        while True:
304            line = infp.readline()
305            if not line:
306                break
307            if line.startswith('# SITE ADDRESSES END'):
308                # It hasn't
309                print >> outfp, '%s\t%s' % (siteaddr, sitedest)
310                print >> outfp, '%s\t%s' % (siteowneraddr, siteownerdest)
311                print >> outfp, '%s\t%s' % (sitebouncesaddr, sitebouncesdest)
312                print >> outfp, '%s\t%s' % (siterequestaddr, siterequestdest)
313                outfp.write(line)
314                break
315            elif line.startswith(siteaddr) or line.startswith('#' + siteaddr):
316                # We just found it
317                outfp.write(line)
318                break
319            else:
320                # This isn't our loop address, so spit it out and continue
321                outfp.write(line)
322        outfp.writelines(infp.readlines())
323    finally:
324        infp.close()
325        outfp.close()
326    os.rename(filename + '.tmp', filename)
327
328
329
330def _do_create(mlist, textfile, func):
331    # Crack open the plain text file
332    try:
333        fp = open(textfile, 'r+')
334    except IOError, e:
335        if e.errno <> errno.ENOENT: raise
336        omask = os.umask(007)
337        try:
338            fp = open(textfile, 'w+')
339        finally:
340            os.umask(omask)
341    try:
342        func(mlist, fp)
343    finally:
344        fp.close()
345    # Now double check the virtual plain text file
346    if func is _addvirtual:
347        _check_for_virtual_loopaddr(mlist, textfile)
348
349
350def create(mlist, cgi=False, nolock=False, quiet=False):
351    # Acquire the global list database lock.  quiet flag is ignored.
352    lock = None
353    if not nolock:
354        lock = makelock()
355        lock.lock()
356    # Do the aliases file, which need to be done in any case
357    try:
358        _do_create(mlist, ALIASFILE, _addlist)
359        if _isvirtual(mlist):
360            _do_create(mlist, VIRTFILE, _addvirtual)
361        # bin/genaliases is the only one that calls create with nolock = True.
362        # Use that to only update the maps at the end of genaliases.
363        if not nolock:
364            _update_maps()
365    finally:
366        if lock:
367            lock.unlock(unconditionally=True)
368
369
370
371def _do_remove(mlist, textfile, virtualp):
372    listname = mlist.internal_name()
373    # Now do our best to filter out the proper stanza from the text file.
374    # The text file better exist!
375    outfp = None
376    try:
377        infp = open(textfile)
378    except IOError, e:
379        if e.errno <> errno.ENOENT: raise
380        # Otherwise, there's no text file to filter so we're done.
381        return
382    try:
383        omask = os.umask(007)
384        try:
385            outfp = open(textfile + '.tmp', 'w')
386        finally:
387            os.umask(omask)
388        filteroutp = False
389        start = '# STANZA START: ' + listname
390        end = '# STANZA END: ' + listname
391        oops = '# STANZA START: '
392        while 1:
393            line = infp.readline()
394            if not line:
395                break
396            # If we're filtering out a stanza, just look for the end marker and
397            # filter out everything in between.  If we're not in the middle of
398            # filtering out a stanza, we're just looking for the proper begin
399            # marker.
400            if filteroutp:
401                if line.strip() == end:
402                    filteroutp = False
403                    # Discard the trailing blank line, but don't worry if
404                    # we're at the end of the file.
405                    infp.readline()
406                elif line.startswith(oops):
407                    # Stanza end must be missing - start writing from here.
408                    filteroutp = False
409                    outfp.write(line)
410                # Otherwise, ignore the line
411            else:
412                if line.strip() == start:
413                    # Filter out this stanza
414                    filteroutp = True
415                else:
416                    outfp.write(line)
417    # Close up shop, and rotate the files
418    finally:
419        infp.close()
420        outfp.close()
421    os.rename(textfile+'.tmp', textfile)
422
423
424def remove(mlist, cgi=False):
425    # Acquire the global list database lock
426    lock = makelock()
427    lock.lock()
428    try:
429        _do_remove(mlist, ALIASFILE, False)
430        if _isvirtual(mlist):
431            _do_remove(mlist, VIRTFILE, True)
432        # Regenerate the alias and map files
433        _update_maps()
434    finally:
435        lock.unlock(unconditionally=True)
436
437
438
439def checkperms(state):
440    for file in ALIASFILE, VIRTFILE:
441        if state.VERBOSE:
442            print C_('checking permissions on %(file)s')
443        stat = None
444        try:
445            stat = os.stat(file)
446        except OSError, e:
447            if e.errno <> errno.ENOENT:
448                raise
449        if stat and (stat[ST_MODE] & targetmode) <> targetmode:
450            state.ERRORS += 1
451            octmode = oct(stat[ST_MODE])
452            print C_('%(file)s permissions must be 0664 (got %(octmode)s)'),
453            if state.FIX:
454                print C_('(fixing)')
455                os.chmod(file, stat[ST_MODE] | targetmode)
456            else:
457                print
458        # Make sure the corresponding .db files are owned by the Mailman user.
459        # We don't need to check the group ownership of the file, since
460        # check_perms checks this itself.
461        dbfile = file + '.db'
462        stat = None
463        try:
464            stat = os.stat(dbfile)
465        except OSError, e:
466            if e.errno <> errno.ENOENT:
467                raise
468            continue
469        if state.VERBOSE:
470            print C_('checking ownership of %(dbfile)s')
471        user = mm_cfg.MAILMAN_USER
472        ownerok = stat[ST_UID] == pwd.getpwnam(user)[2]
473        if not ownerok:
474            try:
475                owner = pwd.getpwuid(stat[ST_UID])[0]
476            except KeyError:
477                owner = 'uid %d' % stat[ST_UID]
478            print C_(
479                '%(dbfile)s owned by %(owner)s (must be owned by %(user)s'),
480            state.ERRORS += 1
481            if state.FIX:
482                print C_('(fixing)')
483                uid = pwd.getpwnam(user)[2]
484                gid = grp.getgrnam(mm_cfg.MAILMAN_GROUP)[2]
485                os.chown(dbfile, uid, gid)
486            else:
487                print
488        if stat and (stat[ST_MODE] & targetmode) <> targetmode:
489            state.ERRORS += 1
490            octmode = oct(stat[ST_MODE])
491            print C_('%(dbfile)s permissions must be 0664 (got %(octmode)s)'),
492            if state.FIX:
493                print C_('(fixing)')
494                os.chmod(dbfile, stat[ST_MODE] | targetmode)
495            else:
496                print
497