Mini Shell

Direktori : /opt/sharedrads/
Upload File :
Current File : //opt/sharedrads/suspend_user

#!/opt/imh-python/bin/python3
import grp
import sys
import re
import pwd
import os
import time
import locale
import subprocess
import syslog
import platform
from pathlib import Path
from argparse import ArgumentParser
from typing import Literal, Union
from pp_api import PowerPanel
from rads.color import red
import rads

pp = PowerPanel()

# Set locale so that the time zone will be logged properly
locale.setlocale(locale.LC_TIME, '')

LOCK_FILE = Path('/var/lock/suspend_user')
LOCK_WAIT = 30  # Seconds to wait to acquire a lock
LOG_FILE = '/var/log/suspension.log'  # Suspension log
PROCNAME = Path(sys.argv[0]).name
# Are we suspending or unsuspending? "unsuspend_user" is a symlink
# to this script
SUSPEND = 'unsuspend' not in PROCNAME

# Is the script being run from a terminal?
TTY = sys.stdout.isatty()

REASON_LIST = (
    'security',
    'ra',
    'moved',
    'billing',
    'canceled',
    'tos',
    'legal',
    'orphan',
    'donotterm',
    'other',
)


def get_groups(user: str) -> list[str]:
    groups = [g.gr_name for g in grp.getgrall() if user in g.gr_mem]
    gid = pwd.getpwnam(user).pw_gid
    groups.append(grp.getgrgid(gid).gr_name)
    return groups


def non_root_safe(user):
    if SUSPEND:
        return
    user = rads.get_login()
    if user == 'root':
        return
    groups = get_groups(user)
    if (
        'tier2s@ipa.imhtech.net' in groups
        or 'managedhosting@ipa.imhtech.net' in groups
    ):
        return
    legit_unsuspend = ['billing', 'moved', 'orphan', 'canceled']
    path = Path('/var/cpanel/suspended', user.lstrip('/'))
    if not path.is_file():
        return
    try:
        data = path.read_text(encoding='utf-8')
    except FileNotFoundError:
        return
    try:
        suspension_reason = data.splitlines()[0].split(':', maxsplit=1)[0]
    except (IndexError, ValueError):
        sys.exit(
            'Unable to determine the reason for suspension. '
            'Please consult tier2s.'
        )
    issues = [
        match for match in legit_unsuspend if match in suspension_reason.lower()
    ]
    if not issues:
        sys.exit(
            "Only root allowed to unsuspend people that are suspended "
            f"for {suspension_reason}"
        )


def print_red(msg: str):
    if TTY:
        print(red(msg))
    else:
        print(msg)


def logdate():
    """Returns the current date and time in a format suitable for
    inclusion in a log file"""
    return time.strftime("%Y-%m-%d:%H:%M:%S %Z", time.localtime(time.time()))


def log_suspension(
    user: str,
    susp_type: str,
    caller: str,
    reason: str,
    duration: str,
    comment: Union[str, None],
):
    """Logs account suspension and unsuspension events"""
    if not comment:
        comment = '-'
    blame = get_calling_username()
    susp_type = susp_type.upper()
    entry = f'{user} [{susp_type}] {reason} {duration} {blame} "{comment}"'
    syslog.openlog(PROCNAME)
    syslog.syslog(entry)
    acquire_lock()
    with open(LOG_FILE, 'a', encoding='utf-8') as file:
        file.write(f'{logdate()} {os.path.basename(caller)}: {entry}\n')
    LOCK_FILE.unlink(missing_ok=True)


def get_calling_username():
    try:
        blame = f'{os.getlogin()}:{pwd.getpwuid(os.geteuid()).pw_name}'
    except OSError:
        blame = pwd.getpwuid(os.geteuid()).pw_name
    return blame


def send_suspension_email(user: str, comment: str, is_temp=False, duration=0):
    """Send suspension email"""
    # IMH - Normal suspension id 7
    # IMH - Temp suspension id 135 variable1 == duration
    # WHH - Normal suspension id 322
    # WHH - Temp suspension id 392 variable1 == duration
    # Reseller - Child acct suspension id 515 variable1 == user
    # Reseller - Child account temp suspension id 516 variable1 == duration,
    # variable2 == user
    if 'hub' in platform.node():
        template_id = 392 if is_temp else 322
        send_to = user
    else:
        # If the customer is the child of a reseller, use the
        # reseller template instead
        owner = rads.get_owner(user)
        if owner not in rads.OUR_RESELLERS:
            template_id = 516 if is_temp else 515
            send_to = owner
        else:
            template_id = 135 if is_temp else 7
            send_to = user

    duration = duration / 60
    template_info = pp.call("notification.fetch-template", template=template_id)
    if template_info.status == 0:
        variables = {}
        for variable in template_info.data['variables']:
            if variable['description'] == "Child User":
                variables[variable['name']] = user
            else:
                variables[variable['name']] = "%s minutes" % duration

        response = pp.call(
            "notification.send",
            template=template_id,
            cpanelUser=send_to,
            **variables,
        )
        if response.status == 0:
            print("Sent email, review at %s" % response.data['reviewUrl'])
            logged_in_user = get_calling_username().split(':')[0]
            if logged_in_user == "root":
                reporter = "auto"
            else:
                reporter = logged_in_user
            pp(
                'hosting.insert-note',
                user=user,
                admin_user=reporter,
                flagged=True,
                type='Suspension',
                # Prepend user to the note because the hosting.insert-note
                # endpoint doesn't seem to honor the 'admin_user' parameter
                # This issue is tracked in Devel #4775
                # https://trac.imhtech.net/Development/ticket/4775
                note=f'{reporter}: {comment}',
            )

            return
    print_red(
        "Could not send suspension email or note acct, please do this manually!"
    )


def suspend_unsuspend(args):
    """Suspends or unsuspends based on the value of the global SUSPEND"""
    if SUSPEND:
        action = 'suspend'
        cmd = [f"/scripts/{action}acct", args.user]
        cmd.append(f'{args.reason}:{args.comment}')
        if args.lock or not args.log_only:
            cmd.append("1")  # lock
    else:
        action = 'unsuspend'
        cmd = [f"/scripts/{action}acct", args.user]
    try:
        subprocess.check_call(cmd, env={'RADSSUSPEND': 'True'})
    except (OSError, subprocess.CalledProcessError):
        print(f'WARNING: Account may not have been properly {action}ed!')


def suspend_special(args, type_str: Literal['suspended', 'autosuspend']):
    """
    Takes the option autosuspend or suspended (the path where sharedrads expects
    to find files for scheduled and temp suspensions
    """
    # Sched and temp suspensions expect slightly different data
    if type_str == 'suspended':
        data = args.duration_str
    else:
        data = args.reason.lower()

    if not os.path.exists('/opt/sharedrads/%s' % type_str):
        os.mkdir('/opt/sharedrads/%s' % type_str)
    path = Path('/opt/sharedrads', type_str, args.user)
    with open(path, 'w', encoding='utf-8') as file:
        # write future timestamp
        file.write(f'{args.duration_secs + int(time.time())} {data}\n')


def archive_logs(user: str):
    user_pwd = pwd.getpwnam(user)
    dotlogs_file = os.path.join('/home', user, '.cpanel-logs')

    with open(dotlogs_file, 'w', encoding='utf-8') as logpref_file:
        logpref_file.write('archive-logs=1\nremove-old-archived-logs=1\n')
    os.chown(dotlogs_file, user_pwd.pw_uid, user_pwd.pw_gid)


def acquire_lock():
    if not LOCK_FILE.exists():
        LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')
        return
    pid = int(LOCK_FILE.read_text(encoding='ascii'))
    try:
        cmdline = Path('/proc', str(pid), 'cmdline').read_text(encoding='ascii')
    except OSError:
        print(f"Stale lock file found, but {pid} isn't running. Moving on...")
        LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')
        return
    # Hard code suspend_user because sys.argv[0] could be unsuspend_user
    if 'suspend_user' not in cmdline:
        print(
            f"Stale lock file found, but {pid}",
            "isn't an instance of this script. Moving on...",
        )
        LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')
        return
    expired = False
    print("Waiting for account suspension lock", end=' ')
    for _ in range(LOCK_WAIT):
        print('.', end=' ', flush=True)
        time.sleep(1)
        if not LOCK_FILE.exists():
            expired = True
            print("Process", pid, "released its lock")
            break
    if not expired:
        print(
            f"Process {pid} hasn't released its lock.",
            "Stealing the lock file forcibly",
        )
    LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')


def parse_args():
    if SUSPEND:
        parser = ArgumentParser(
            description='Suspend user',
            usage="%(prog)s username [duration|delay] [options] "
            "[--sched|-s] [--lock|-l] -r reason",
        )
    else:
        parser = ArgumentParser(
            description='Unsuspend user',
            usage='%(prog)s username [options]',
        )
    # fmt: off
    parser.add_argument(
        "-c", "--comment", dest="comment",
        help="additional comment to place in the suspension log",
    )
    if not TTY:
        parser.add_argument("--info", action="store_true", dest="info")
        parser.add_argument("--invoked-by", dest="caller", default="unknown")
    if SUSPEND:
        parser.add_argument(
            "-s", "--sched", action="store_true", dest="sched",
            help="do not suspend immediately; schedule a suspension "
            "to occur later",
        )
        parser.add_argument(
            "-l", "--lock", action="store_const", const="lock", dest="lock",
            help="lock the suspension: non-root reseller cannot unsuspend",
        )
        reason_group = parser.add_mutually_exclusive_group(required=True)
        reason_group.add_argument(
            "-r", "--reason", dest="reason", choices=REASON_LIST,
            help="reason for the suspension",
        )
        reason_group.add_argument(
            "--ra", action="store_const", const="ra", dest="reason",
            help="suspended for RA",
        )
        reason_group.add_argument(
            "--moved", "--move", action="store_const",
            const="moved", dest="reason",
            help="account was moved",
        )
        reason_group.add_argument(
            "--billing", action="store_const", const="billing", dest="reason",
            help="suspended for billing purposes",
        )
        reason_group.add_argument(
            "--canceled", action="store_const", const="canceled", dest="reason",
            help="suspended because of cancellation",
        )
        reason_group.add_argument(
            "--tos", action="store_const", const="tos", dest="reason",
            help="suspended for ToS violation",
        )
        reason_group.add_argument(
            "--legal", action="store_const", const="legal", dest="reason",
            help="suspended for legal reasons",
        )
        reason_group.add_argument(
            "--security", action="store_const", const="security", dest="reason",
            help="suspended for security violation",
        )
        reason_group.add_argument(
            "--orphan", action="store_const", const="orphan", dest="reason",
            help="account is orphaned",
        )
        reason_group.add_argument(
            "--donotterm", action="store_const",
            const="donotterm", dest="reason",
            help="do not terminate account",
        )
    # fmt: on
    args, extras = parser.parse_known_args()
    if TTY:
        args.info = None
        args.caller = None
    args.user = None
    args.log_only = False
    args.duration_secs = None
    args.duration_str = ''

    # Regular expression that must be valid for temp or scheduled suspensions
    time_re = re.compile(r'^(\d+)([dhm])$')
    # Number of seconds in a minute, hour, day
    secs = {'m': 60, 'h': 3600, 'd': 86400}
    # loop through positional args
    for pos_arg in extras:
        try:
            uid = pwd.getpwnam(pos_arg).pw_uid
        except Exception:
            uid = None

        # If string exists in the password database and is a cPanel user
        # Assign as the user name to act upon
        if uid is not None and os.path.exists(f'/var/cpanel/users/{pos_arg}'):
            args.user = pos_arg

        # If string matches the time regexp set it as args.duration_*
        elif match := time_re.match(pos_arg):
            dur, unit = match.groups()
            args.duration_secs = int(dur) * secs[unit]
            args.duration_str = pos_arg

        elif 'log_only' in pos_arg:
            args.log_only = True

        # If string doesn't match assume it's the comment
        else:
            args.comment = pos_arg

    # Require a valid user
    if args.user is None:
        print("ERROR: No valid user specified")
        sys.exit(1)
    return args


def main():
    args = parse_args()

    if args.caller:
        invoking_process = args.caller
    else:
        invoking_process = PROCNAME

    if args.log_only and 'RADSSUSPEND' in os.environ:
        # Don't actually suspend, just update the suspension log, this
        # condition is meant to be triggered by cPanel suspension hooks
        if SUSPEND:
            log_suspension(
                user=args.user,
                susp_type='cp_suspension',
                caller=invoking_process,
                reason='-',
                duration='perm',
                comment=args.comment,
            )
            archive_logs(args.user)
        else:
            log_suspension(
                user=args.user,
                susp_type='cp_unsuspension',
                caller=invoking_process,
                reason='-',
                duration='-',
                comment=args.comment,
            )
            archive_logs(args.user)

    elif SUSPEND:
        # If invoked with the --info option just add a note
        # Only maint scripts should call suspend_user in this fashion
        if args.reason.lower() == "donotterm" and not args.comment:
            args.comment = "Marked as Do Not Terminate"

        if not TTY and args.info:
            log_suspension(
                user=args.user,
                susp_type='info',
                caller=invoking_process,
                reason=args.reason,
                duration='-',
                comment=args.comment,
            )

        # Is the user already suspended?
        elif not rads.cpuser_suspended(args.user):
            # Display an error and exit if a scheduled suspension is chosen
            # but d is None
            if args.duration_secs is None and args.sched:
                print("ERROR: Scheduled suspensions require a valid delay!")
                sys.exit(1)

            # Temp suspension
            elif args.duration_secs is not None and not args.sched:
                print_red(
                    f'Suspending {args.user} (reason: {args.reason}) '
                    f'for {args.duration_secs} seconds ({args.duration_str}) '
                    f'with comment "{args.comment}"'
                )
                if args.reason.lower() == "ra":
                    if not args.comment:
                        print_red(
                            "Account note is required for RA suspensions! "
                            "Not suspending user!"
                        )
                        sys.exit(1)
                    send_suspension_email(
                        args.user,
                        args.comment,
                        is_temp=True,
                        duration=args.duration_secs,
                    )
                log_suspension(
                    user=args.user,
                    susp_type='suspension',
                    caller=invoking_process,
                    reason=args.reason,
                    duration='temp:%s' % args.duration_str,
                    comment=args.comment,
                )
                suspend_special(args, 'suspended')
                suspend_unsuspend(args)
                archive_logs(args.user)

            # Sched suspension
            elif args.duration_secs is not None and args.sched:
                print_red(
                    f'Suspending {args.user} (reason: {args.reason}) '
                    f'{args.duration_secs} seconds ({args.duration_str}) '
                    f'from now with comment "{args.comment}"'
                )
                log_suspension(
                    user=args.user,
                    susp_type='sched_suspension',
                    caller=invoking_process,
                    reason=args.reason,
                    duration='sched:%s' % args.duration_str,
                    comment=args.comment,
                )
                suspend_special(args, 'autosuspend')

            # Auto suspension
            elif not TTY and 'autosuspend' in args.caller:
                log_suspension(
                    user=args.user,
                    susp_type='auto_suspension',
                    caller=invoking_process,
                    reason=args.reason,
                    duration='perm',
                    comment=args.comment,
                )
                suspend_unsuspend(args)
                archive_logs(args.user)

            # Perm suspension
            elif args.duration_secs is None and not args.sched:
                print_red(
                    f'Suspending {args.user} (reason: {args.reason}) '
                    f'permanently with comment "{args.comment}"'
                )
                if args.reason.lower() == "ra":
                    if not args.comment:
                        print_red(
                            "Account note is required for RA suspensions! "
                            "Not suspending user!"
                        )
                        sys.exit(1)
                    send_suspension_email(
                        args.user, args.comment, is_temp=False
                    )
                log_suspension(
                    user=args.user,
                    susp_type='suspension',
                    caller=invoking_process,
                    reason=args.reason,
                    duration='perm',
                    comment=args.comment,
                )
                suspend_unsuspend(args)
                archive_logs(args.user)
        else:
            print("ERROR: %s already appears to be suspended!" % args.user)
            sys.exit(1)
    else:
        if rads.cpuser_suspended(args.user):
            non_root_safe(args.user)
            print_red(
                'Unsuspending {} with comment "{}"'.format(
                    args.user, args.comment
                )
            )
            log_suspension(
                user=args.user,
                susp_type='unsuspension',
                caller=invoking_process,
                reason='-',
                duration='-',
                comment=args.comment,
            )
            suspend_unsuspend(args)
            try:
                os.unlink('/opt/sharedrads/suspended/%s' % args.user)
                print_red('Removed RADS temp suspension file')
            except Exception:
                pass
        else:
            print("ERROR: %s does not appear to be suspended!" % args.user)
            sys.exit(1)


if __name__ == '__main__':
    main()

Zerion Mini Shell 1.0