Mini Shell
#!/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