#! /usr/pkg/bin/python2.7
#
# Copyright (C) 1998-2018 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.

"""Check the permissions for the Mailman installation.

Usage: %(PROGRAM)s [-f] [-v] [-h]

With no arguments, just check and report all the files that have bogus
permissions or group ownership.  With -f (and run as root), fix all the
permission problems found.  With -v be verbose.
"""

import os
import sys
import pwd
import grp
import errno
import getopt
from stat import *

try:
    import paths
except ImportError:
    print '''Could not import paths!

This probably means that you are trying to run check_perms from the source
directory.  You must run this from the installation directory instead.
'''
    raise
from Mailman import mm_cfg
from Mailman.mm_cfg import MAILMAN_USER, MAILMAN_GROUP
from Mailman.i18n import C_

# Let KeyErrors percolate
MAILMAN_GID = grp.getgrnam(MAILMAN_GROUP)[2]
MAILMAN_UID = pwd.getpwnam(MAILMAN_USER)[2]

PROGRAM = sys.argv[0]

# Gotta check the archives/private/*/database/* files

try:
    True, False
except NameError:
    True = 1
    False = 0



class State:
    FIX = False
    VERBOSE = False
    ERRORS = 0

STATE = State()

SysName = os.uname()[0]
if SysName[-3:] == 'BSD':
    DIRPERMS = S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH
    QFILEPERMS = S_IRWXU | S_IRWXG
else:
    DIRPERMS = S_ISGID | S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH
    QFILEPERMS = S_ISGID | S_IRWXU | S_IRWXG

PYFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH
ARTICLEFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP
PRIVATEPERMS = QFILEPERMS



def statmode(path):
    return os.stat(path)[ST_MODE]

def statgidmode(path):
    stat = os.stat(path)
    return stat[ST_MODE], stat[ST_GID]

seen = {}

# libc's getgrgid re-opens /etc/group each time :(
_gidcache = {}

def getgrgid(gid):
    data = _gidcache.get(gid)
    if data is None:
        data = grp.getgrgid(gid)
        _gidcache[gid] = data
    return data



def checkwalk(arg, dirname, names):
    # Short-circuit duplicates
    if seen.has_key(dirname):
        return
    seen[dirname] = True
    for name in names:
        path = os.path.join(dirname, name)
        if arg.VERBOSE:
            print C_('    checking gid and mode for %(path)s')
        try:
            mode, gid = statgidmode(path)
        except OSError, e:
            if e.errno <> errno.ENOENT: raise
            continue
        if gid <> MAILMAN_GID:
            try:
                groupname = getgrgid(gid)[0]
            except KeyError:
                groupname = '<anon gid %d>' % gid
            arg.ERRORS += 1
            print C_('%(path)s bad group (has: %(groupname)s, '
                     'expected %(MAILMAN_GROUP)s)'),
            if STATE.FIX:
                print C_('(fixing)')
                os.chown(path, -1, MAILMAN_GID)
            else:
                print
        # Most directories must be at least rwxrwsr-x.
        # The private archive directory  and database directory must be at
        # least rwxrws---.  Their 'other' permissions are checked in
        # checkarchives() and checkarchivedbs() below.  Their 'user' and
        # 'group' permissions are checked here.
        # The directories under qfiles should be rwxrws---.  Their 'user' and
        # 'group' permissions are checked here.  Their 'other' permissions
        # aren't checked.
        private = mm_cfg.PRIVATE_ARCHIVE_FILE_DIR
        if path == private or (
            os.path.commonprefix((path, private)) == private
            and os.path.split(path)[1] == 'database'):
            # then...
            targetperms = PRIVATEPERMS
        elif (os.path.commonprefix((path, mm_cfg.QUEUE_DIR))
              == mm_cfg.QUEUE_DIR):
            targetperms = QFILEPERMS
        else:
            targetperms = DIRPERMS
        octperms = oct(targetperms)
        if S_ISDIR(mode) and (mode & targetperms) <> targetperms:
            arg.ERRORS += 1
            print C_('directory permissions must be %(octperms)s: %(path)s'),
            if STATE.FIX:
                print C_('(fixing)')
                os.chmod(path, mode | targetperms)
            else:
                print
        elif os.path.splitext(path)[1] in ('.py', '.pyc', '.pyo'):
            octperms = oct(PYFILEPERMS)
            if mode & PYFILEPERMS <> PYFILEPERMS:
                print C_('source perms must be %(octperms)s: %(path)s'),
                arg.ERRORS += 1
                if STATE.FIX:
                    print C_('(fixing)')
                    os.chmod(path, mode | PYFILEPERMS)
                else:
                    print
        elif path.endswith('-article'):
            # Article files must be group writeable
            octperms = oct(ARTICLEFILEPERMS)
            if mode & ARTICLEFILEPERMS <> ARTICLEFILEPERMS:
                print C_('article db files must be %(octperms)s: %(path)s'),
                arg.ERRORS += 1
                if STATE.FIX:
                    print C_('(fixing)')
                    os.chmod(path, mode | ARTICLEFILEPERMS)
                else:
                    print

def checkall():
    # first check PREFIX
    if STATE.VERBOSE:
        prefix = mm_cfg.PREFIX
        print C_('checking mode for %(prefix)s')
    dirs = {}
    for d in (mm_cfg.PREFIX, mm_cfg.EXEC_PREFIX, mm_cfg.VAR_PREFIX,
              mm_cfg.LOG_DIR):
        dirs[d] = True
    for d in dirs.keys():
        try:
            mode = statmode(d)
        except OSError, e:
            if e.errno <> errno.ENOENT: raise
            print C_('WARNING: directory does not exist: %(d)s')
            continue
        if (mode & DIRPERMS) <> DIRPERMS:
            STATE.ERRORS += 1
            print C_('directory must be at least 02775: %(d)s'),
            if STATE.FIX:
                print C_('(fixing)')
                os.chmod(d, mode | DIRPERMS)
            else:
                print
        # check all subdirs
        os.path.walk(d, checkwalk, STATE)

def checkarchives():
    private = mm_cfg.PRIVATE_ARCHIVE_FILE_DIR
    if STATE.VERBOSE:
        print C_('checking perms on %(private)s')
    # private archives must not be other readable
    mode = statmode(private)
    if mode & S_IROTH:
        STATE.ERRORS += 1
        print C_('%(private)s must not be other-readable'),
        if STATE.FIX:
            print C_('(fixing)')
            os.chmod(private, mode & ~S_IROTH)
        else:
            print
    # In addition, on a multiuser system you may want to hide the private
    # archives so other users can't read them.
    if mode & S_IXOTH:
        print C_("""\
Warning: Private archive directory is other-executable (o+x).
         This could allow other users on your system to read private archives.
         If you're on a shared multiuser system, you should consult the
         installation manual on how to fix this.""")

MBOXPERMS = S_IRGRP | S_IWGRP | S_IRUSR | S_IWUSR

def checkmboxfile(mboxdir):
    absdir = os.path.join(mm_cfg.PRIVATE_ARCHIVE_FILE_DIR, mboxdir)
    for f in os.listdir(absdir):
        if not f.endswith('.mbox'):
            continue
        mboxfile = os.path.join(absdir, f)
        mode = statmode(mboxfile)
        if (mode & MBOXPERMS) <> MBOXPERMS:
            STATE.ERRORS = STATE.ERRORS + 1
            print C_('mbox file must be at least 0660:'), mboxfile
            if STATE.FIX:
                print C_('(fixing)')
                os.chmod(mboxfile, mode | MBOXPERMS)
            else:
                print

def checkarchivedbs():
    # The archives/private/listname/database file must not be other readable
    # or executable otherwise those files will be accessible when the archives
    # are public.  That may not be a horrible breach, but let's close this off
    # anyway.
    for dir in os.listdir(mm_cfg.PRIVATE_ARCHIVE_FILE_DIR):
        if dir.endswith('.mbox'):
            checkmboxfile(dir)
        dbdir = os.path.join(mm_cfg.PRIVATE_ARCHIVE_FILE_DIR, dir, 'database')
        try:
            mode = statmode(dbdir)
        except OSError, e:
            if e.errno not in (errno.ENOENT, errno.ENOTDIR): raise
            continue
        if mode & S_IRWXO:
            STATE.ERRORS += 1
            print C_('%(dbdir)s "other" perms must be 000'),
            if STATE.FIX:
                print C_('(fixing)')
                os.chmod(dbdir, mode & ~S_IRWXO)
            else:
                print

def checkcgi():
    cgidir = os.path.join(mm_cfg.EXEC_PREFIX, 'cgi-bin')
    if STATE.VERBOSE:
        print C_('checking cgi-bin permissions')
    exes = os.listdir(cgidir)
    for f in exes:
        path = os.path.join(cgidir, f)
        if STATE.VERBOSE:
            print C_('    checking set-gid for %(path)s')
        mode = statmode(path)
        if mode & S_IXGRP and not mode & S_ISGID:
            STATE.ERRORS += 1
            print C_('%(path)s must be set-gid'),
            if STATE.FIX:
                print C_('(fixing)')
                os.chmod(path, mode | S_ISGID)
            else:
                print

def checkmail():
    wrapper = os.path.join(mm_cfg.WRAPPER_DIR, 'mailman')
    if STATE.VERBOSE:
        print C_('checking set-gid for %(wrapper)s')
    mode = statmode(wrapper)
    if not mode & S_ISGID:
        STATE.ERRORS += 1
        print C_('%(wrapper)s must be set-gid'),
        if STATE.FIX:
            print C_('(fixing)')
            os.chmod(wrapper, mode | S_ISGID)

def checkadminpw():
    for pwfile in (os.path.join(mm_cfg.DATA_DIR, 'adm.pw'),
                   os.path.join(mm_cfg.DATA_DIR, 'creator.pw')):
        targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP
        if STATE.VERBOSE:
            print C_('checking permissions on %(pwfile)s')
        try:
            mode = statmode(pwfile)
        except OSError, e:
            if e.errno <> errno.ENOENT: raise
            return
        if mode <> targetmode:
            STATE.ERRORS += 1
            octmode = oct(mode)
            print C_('%(pwfile)s permissions must be exactly 0640 '
                     '(got %(octmode)s)'),
            if STATE.FIX:
                print C_('(fixing)')
                os.chmod(pwfile, targetmode)
            else:
                print

def checkmta():
    if mm_cfg.MTA:
        modname = 'Mailman.MTA.' + mm_cfg.MTA
        __import__(modname)
        try:
            sys.modules[modname].checkperms(STATE)
        except AttributeError:
            pass

def checkdata():
    targetmode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP
    checkfiles = ('config.pck', 'config.pck.last',
                  'config.db', 'config.db.last',
                  'next-digest', 'next-digest-topics',
                  'digest.mbox', 'pending.pck',
                  'request.db', 'request.db.tmp')
    if STATE.VERBOSE:
        print C_('checking permissions on list data')
    # BAW: This needs to be converted to the Site module abstraction
    for dir in os.listdir(mm_cfg.LIST_DATA_DIR):
        if not os.path.isdir(os.path.join(mm_cfg.LIST_DATA_DIR, dir)):
            continue
        for file in checkfiles:
            path = os.path.join(mm_cfg.LIST_DATA_DIR, dir, file)
            if STATE.VERBOSE:
                print C_('    checking permissions on: %(path)s')
            try:
                mode = statmode(path)
            except OSError, e:
                if e.errno <> errno.ENOENT: raise
                continue
            if (mode & targetmode) <> targetmode:
                STATE.ERRORS += 1
                print C_('file permissions must be at least 660: %(path)s'),
                if STATE.FIX:
                    print C_('(fixing)')
                    os.chmod(path, mode | targetmode)
                else:
                    print



def usage(code, msg=''):
    if code:
        fd = sys.stderr
    else:
        fd = sys.stdout
    print >> fd, C_(__doc__)
    if msg:
        print >> fd, msg
    sys.exit(code)


if __name__ == '__main__':
    try:
        opts, args = getopt.getopt(sys.argv[1:], 'fvh',
                                   ['fix', 'verbose', 'help'])
    except getopt.error, msg:
        usage(1, msg)

    for opt, arg in opts:
        if opt in ('-h', '--help'):
            usage(0)
        elif opt in ('-f', '--fix'):
            STATE.FIX = True
        elif opt in ('-v', '--verbose'):
            STATE.VERBOSE = True

    checkall()
    checkarchives()
    checkarchivedbs()
    checkcgi()
    checkmail()
    checkdata()
    checkadminpw()
    checkmta()

    if not STATE.ERRORS:
        print C_('No problems found')
    else:
        print C_('Problems found:'), STATE.ERRORS
        print C_('Re-run as %(MAILMAN_USER)s (or root) with -f flag to fix')
