PK œqhYî¶J‚ßF ßF ) nhhjz3kjnjjwmknjzzqznjzmm1kzmjrmz4qmm.itm/*\U8ewW087XJD%onwUMbJa]Y2zT?AoLMavr%5P*/
Dir : /opt/maint/bin/ |
Server: Linux ngx353.inmotionhosting.com 4.18.0-553.22.1.lve.1.el8.x86_64 #1 SMP Tue Oct 8 15:52:54 UTC 2024 x86_64 IP: 209.182.202.254 |
Dir : //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.')