#!/opt/imh-python/bin/python3
"""Removes suspended users after a certain period of time"""
from functools import cached_property
from argparse import ArgumentParser
from typing import Literal
from configparser import ConfigParser
from cpapis import whmapi1
from pp_api import PowerPanel
class Config(ConfigParser):
"""Parses autoterminate.cfg"""
super().__init__(allow_no_value=False)
config_file = '/opt/maint/etc/autoterminate.cfg'
if not self.read(config_file):
raise FileNotFoundError(config_file)
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')}
"""Collects user info from whmapi1 accountsummary"""
def __init__(self, user: str):
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
user for user, owner in USER_OWNERS.items() if owner == user
self.suspend_comment = acct.get('suspendreason', '')
mtime = Path('/var/cpanel/suspended', user).stat().st_mtime
self.days_suspended = int((time.time() - mtime) / secs_in_day)
self.suspend_comment = ''
self.keep_dns = int(bool(re.match(r'moved?:', self.suspend_comment)))
def __repr__(self) -> str:
f"{self.user} ({self.suspend_comment}, "
f"suspended {self.days_suspended} days)"
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
if short_reason not in SUSPEND_REASONS:
# We don't know why the account was suspended
"""Evaluates whether a user meets the criteria for termination"""
if not self.is_suspended:
# Has the account been suspended long enough?
days_needed = CONF.terminations[reason]
"%s - term length not defined for reason %r", self.user, reason
return False # terms not defined for this reason
LOGGER.debug("%s - terms disabled for %r", self.user, reason)
return False # terms disabled in config
if self.days_suspended < days_needed:
"%s - not ready for term (suspended %d/%d days)",
def set_pp_status_reclaimed(user: str):
"""Notify PowerPanel that the user has been terminated"""
'hosting-server.get-status', username=user, machine=MACHINE
if row['status'] == "approved" or row['status'] == "suspended":
'hosting-server.set-status',
"PowerPanel reclamation status: %s (%s)",
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():
homedir = rads.get_homedir(user)
['ionice', '-c2', '-n7', 'rm', '-rf', homedir],
capture_output=True, # adds output to exception if raised
whmapi1('removeacct', {'user': user, 'keepdns': keep_dns}, check=True)
LOGGER.error('%s - %s: %s', user, type(exc).__name__, exc)
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.",
def send_ticket(dryrun: bool, user: str, message: str):
"""Sends a reclamation request"""
LOGGER.warning('Creating reclamations ticket for %s', user)
to_addr="reclamations@imhadmin.net",
subject=f"[{MACHINE}] Please review/terminate user {user}",
def local_dns(ip_list: list[str], user: str) -> str:
"""Checks to see if any domains in a user account are pointed locally"""
data = rads.UserData(user)
except rads.CpuserError as exc:
LOGGER.warning('%s - %s', user, exc)
domains = [data.primary.domain]
domains.extend([x.domain for x in data.parked])
domains.extend([x.domain for x in data.addons])
addr = socket.gethostbyname(domain)
parser = ArgumentParser(description=__doc__)
'-d', '--dryrun', action='store_true',
help='Test mode - Do not terminate any accounts or create tickets',
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:
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)
if rads.IMH_CLASS == 'reseller':
if owner not in rads.OUR_RESELLERS:
if user not in ALL_RESELLERS:
LOGGER.warning('%s may be an orphaned account', user)
with open('/etc/ips', encoding='utf-8') as file:
yield line.split(':', maxsplit=1)[0].strip()
with open('/var/cpanel/mainip', encoding='utf-8') as file:
yield file.read().strip()
dryrun: bool = parse_args().dryrun
LOGGER.info('Starting next run with --dryrun')
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')):
LOGGER.error('%s - %s: %s', user, type(exc).__name__, exc)
if not data.can_terminate:
if data.reason not in ('billing', 'canceled', 'ra', 'tos'):
if local := local_dns(ip_list, user):
"%s - domains are still pointed to this server", user
f"Cannot terminate {user} - domain(s) are still pointed to "
f"this server:\n\n{local}",
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"
set_pp_status_reclaimed(user)
# Make sure apache will restart normally again
APACHE_NO_RESTART.unlink(missing_ok=True)
if __name__ == '__main__':
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)
'ra': re.compile('ra', flags=re.IGNORECASE),
'tos': re.compile('tos', flags=re.IGNORECASE),
r'active queue|suspension queue|billing|\[PP2 [A-Za-z]+\]',
'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),
r'move|\[PP2 [A-Za-z]+\] - Reason: Account[ ]*Consolidation',
# 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'
with rads.lock('auto_terminate'):
LOGGER.critical('Another instance is already running. Exiting.')