Mini Shell

Direktori : /opt/maint/bin/
Upload File :
Current File : //opt/maint/bin/auto_terminate.py

#!/opt/imh-python/bin/python3
"""Removes suspended users after a certain period of time"""

from functools import cached_property
import os
import platform
from pathlib import Path
from argparse import ArgumentParser
import re
import socket
from typing import Literal
import time
from configparser import ConfigParser
from cpapis import whmapi1
from pp_api import PowerPanel
from cproc import Proc
import rads


class Config(ConfigParser):
    """Parses autoterminate.cfg"""

    def __init__(self):
        super().__init__(allow_no_value=False)
        config_file = '/opt/maint/etc/autoterminate.cfg'
        if not self.read(config_file):
            raise FileNotFoundError(config_file)

    @cached_property
    def terminations(self) -> dict[str, int]:
        """How many days an account should be suspended before termination.
        A value of 0 will disable the termination"""
        return {k: int(v) for k, v in CONF.items('terminations')}


class UserInfo:
    """Collects user info from whmapi1 accountsummary"""

    user: str
    owner: str
    is_suspended: bool
    suspend_comment: str
    days_suspended: int
    is_reseller: bool
    children: list[str]
    keep_dns: Literal[0, 1]

    def __init__(self, user: str):
        self.user = user
        acct = whmapi1('accountsummary', {'user': user}, check=True)
        acct: dict = acct['data']['acct'][0]
        self.owner = acct['owner']
        self.is_suspended = bool(acct['suspended'])
        if not self.is_suspended:
            Path('/var/cpanel/suspended', user).unlink(missing_ok=True)
        self.is_reseller = user in ALL_RESELLERS
        if self.is_reseller:
            self.children = [
                user for user, owner in USER_OWNERS.items() if owner == user
            ]
        else:
            self.children = []
        if self.is_suspended:
            self.suspend_comment = acct.get('suspendreason', '')
            mtime = Path('/var/cpanel/suspended', user).stat().st_mtime
            secs_in_day = 86400
            self.days_suspended = int((time.time() - mtime) / secs_in_day)
        else:
            self.suspend_comment = ''
            self.days_suspended = 0
        self.keep_dns = int(bool(re.match(r'moved?:', self.suspend_comment)))

    def __repr__(self) -> str:
        return (
            f"{self.user} ({self.suspend_comment}, "
            f"suspended {self.days_suspended} days)"
        )

    @cached_property
    def reason(self) -> str:
        short_reason = self.suspend_comment.split(':', maxsplit=1)[0]
        if short_reason not in SUSPEND_REASONS:
            # The user may have been suspended manually or via PP.
            # For legacy support, try to figure it out
            for this_reason, regex in SUSPEND_REASONS.items():
                if regex.search(self.suspend_comment):
                    short_reason = this_reason
                    break
        if short_reason not in SUSPEND_REASONS:
            # We don't know why the account was suspended
            short_reason = 'other'
        return short_reason

    @property
    def can_terminate(self):
        """Evaluates whether a user meets the criteria for termination"""
        if not self.is_suspended:
            return False
        reason = self.reason
        # Has the account been suspended long enough?
        try:
            days_needed = CONF.terminations[reason]
        except KeyError:
            LOGGER.warning(
                "%s - term length not defined for reason %r", self.user, reason
            )
            return False  # terms not defined for this reason
        if days_needed <= 0:
            LOGGER.debug("%s - terms disabled for %r", self.user, reason)
            return False  # terms disabled in config
        if self.days_suspended < days_needed:
            LOGGER.debug(
                "%s - not ready for term (suspended %d/%d days)",
                self.user,
                self.days_suspended,
                days_needed,
            )
            return False
        return True


def set_pp_status_reclaimed(user: str):
    """Notify PowerPanel that the user has been terminated"""
    amp = PowerPanel()
    results = amp.call(
        'hosting-server.get-status', username=user, machine=MACHINE
    )
    for row in results.data:
        if row['status'] == "approved" or row['status'] == "suspended":
            set_status = amp.call(
                'hosting-server.set-status',
                username=user,
                machine=MACHINE,
                status='reclaimed',
                id=row['id'],
            )
            LOGGER.info(
                "PowerPanel reclamation status: %s (%s)",
                set_status.status,
                set_status.message,
            )
            if set_status.status != 200:
                LOGGER.warning("PowerPanel reclamation failed!")


def terminate_user(dryrun: bool, user: str, keep_dns: Literal[0, 1]) -> bool:
    """Handles user termination"""
    path = Path('/var/cpanel/suspended', user)
    if dryrun or 'donotterm' in path.read_text('utf-8').lower():
        return False
    try:
        homedir = rads.get_homedir(user)
        Proc.run(
            ['ionice', '-c2', '-n7', 'rm', '-rf', homedir],
            lim=os.cpu_count(),
            check=False,
            encoding=None,
            capture_output=True,  # adds output to exception if raised
        )
        whmapi1('removeacct', {'user': user, 'keepdns': keep_dns}, check=True)
    except Exception as exc:
        LOGGER.error('%s - %s: %s', user, type(exc).__name__, exc)
        send_ticket(
            dryrun,
            user,
            f"auto_terminate encountered an error trying to terminate {user}.\n"
            f"{type(exc).__name__}: {exc}\n"
            "Please check on this account and removeacct it if needed.",
        )
        return False
    return True


def send_ticket(dryrun: bool, user: str, message: str):
    """Sends a reclamation request"""
    LOGGER.warning('Creating reclamations ticket for %s', user)
    if dryrun:
        return
    rads.send_email(
        to_addr="reclamations@imhadmin.net",
        subject=f"[{MACHINE}] Please review/terminate user {user}",
        body=message,
    )


def local_dns(ip_list: list[str], user: str) -> str:
    """Checks to see if any domains in a user account are pointed locally"""
    try:
        data = rads.UserData(user)
    except rads.CpuserError as exc:
        LOGGER.warning('%s - %s', user, exc)
        return ""
    domains = [data.primary.domain]
    domains.extend([x.domain for x in data.parked])
    domains.extend([x.domain for x in data.addons])
    local = []

    for domain in domains:
        try:
            addr = socket.gethostbyname(domain)
        except OSError:
            continue
        if addr in ip_list:
            local.append(domain)
    return '\n'.join(local)


def parse_args():
    """Parse sys.argv"""
    parser = ArgumentParser(description=__doc__)
    # fmt: off
    parser.add_argument(
        '-d', '--dryrun', action='store_true',
        help='Test mode - Do not terminate any accounts or create tickets',
    )
    # fmt: on
    return parser.parse_args()


def valid_user(user: str) -> bool:
    """Used to filter /var/cpanel/suspended to users we may take action on"""
    if user.endswith('.lock') or user in rads.OUR_RESELLERS:
        return False
    try:
        owner = USER_OWNERS[user]
    except KeyError:  # user does not exist
        assert not user.startswith('..') and not user.startswith('/')
        Path('/var/cpanel/suspended', user).unlink(missing_ok=True)
        return False
    if rads.IMH_CLASS == 'reseller':
        if owner not in rads.OUR_RESELLERS:
            return False
        if user not in ALL_RESELLERS:
            LOGGER.warning('%s may be an orphaned account', user)
            return False
    return True


def iter_ips():
    """Iterate system IPs"""
    with open('/etc/ips', encoding='utf-8') as file:
        for line in file:
            yield line.split(':', maxsplit=1)[0].strip()
    with open('/var/cpanel/mainip', encoding='utf-8') as file:
        yield file.read().strip()


def main():
    dryrun: bool = parse_args().dryrun
    if dryrun:
        LOGGER.info('Starting next run with --dryrun')
    else:
        LOGGER.info('Starting next run')
        APACHE_NO_RESTART.touch(mode=0o644, exist_ok=True)
    ip_list = list(filter(None, iter_ips()))
    for user in filter(valid_user, os.listdir('/var/cpanel/suspended')):
        try:
            data = UserInfo(user)
        except Exception as exc:
            LOGGER.error('%s - %s: %s', user, type(exc).__name__, exc)
            continue
        if not data.can_terminate:
            continue
        if data.reason not in ('billing', 'canceled', 'ra', 'tos'):
            if local := local_dns(ip_list, user):
                LOGGER.warning(
                    "%s - domains are still pointed to this server", user
                )
                send_ticket(
                    dryrun,
                    user,
                    f"Cannot terminate {user} - domain(s) are still pointed to "
                    f"this server:\n\n{local}",
                )
                continue
        if rads.IMH_CLASS == 'reseller':
            # If this is a reseller, terminate their child accounts first
            for child in data.children:
                LOGGER.info("Terminating sub-user %s (owner: %s)", child, user)
                terminate_user(dryrun, user, data.keep_dns)
        LOGGER.info("Terminating user %r", data)
        terminate_user(dryrun, user, data.keep_dns)
        # Set account status to 'reclaimed' in PowerPanel if not 'moved'
        # keep_dns is 1 if "moved"
        if not data.keep_dns:
            set_pp_status_reclaimed(user)
    # Make sure apache will restart normally again
    APACHE_NO_RESTART.unlink(missing_ok=True)


if __name__ == '__main__':
    CONF = Config()
    MACHINE = platform.node().split('.', maxsplit=1)[0]
    # Ref: https://confluence1.cpanel.net/display/EA/Flag+Files
    APACHE_NO_RESTART = Path('/var/cpanel/mgmt_queue/apache_update_no_restart')
    ALL_RESELLERS = whmapi1.listresellers()
    USER_OWNERS = rads.all_cpusers(owners=True)
    SUSPEND_REASONS = {
        'ra': re.compile('ra', flags=re.IGNORECASE),
        'tos': re.compile('tos', flags=re.IGNORECASE),
        'billing': re.compile(
            r'active queue|suspension queue|billing|\[PP2 [A-Za-z]+\]',
            flags=re.IGNORECASE,
        ),
        'legal': re.compile('legal', flags=re.IGNORECASE),
        'donotterm': re.compile('donotterm', flags=re.IGNORECASE),
        'chargeback': re.compile('chargeback', flags=re.IGNORECASE),
        'canceled': re.compile(r'cancel\|refund', flags=re.IGNORECASE),
        'moved': re.compile(
            r'move|\[PP2 [A-Za-z]+\] - Reason: Account[ ]*Consolidation',
            flags=re.IGNORECASE,
        ),
    }
    # cron config appends stdout/err to /var/log/maint/auto_terminate.log
    LOGGER = rads.setup_logging(
        path=None, name='auto_terminate', loglevel='DEBUG', print_out='stdout'
    )
    try:
        with rads.lock('auto_terminate'):
            main()
    except rads.LockError:
        LOGGER.critical('Another instance is already running. Exiting.')

Zerion Mini Shell 1.0