Edit File by line
/home/barbar84/public_h.../wp-conte.../plugins/sujqvwi/AnonR/smanonr..../opt/maint/bin
File: auto_terminate.py
#!/opt/imh-python/bin/python3
[0] Fix | Delete
"""Removes suspended users after a certain period of time"""
[1] Fix | Delete
[2] Fix | Delete
from functools import cached_property
[3] Fix | Delete
import os
[4] Fix | Delete
import platform
[5] Fix | Delete
from pathlib import Path
[6] Fix | Delete
from argparse import ArgumentParser
[7] Fix | Delete
import re
[8] Fix | Delete
import socket
[9] Fix | Delete
from typing import Literal
[10] Fix | Delete
import time
[11] Fix | Delete
from configparser import ConfigParser
[12] Fix | Delete
from cpapis import whmapi1
[13] Fix | Delete
from pp_api import PowerPanel
[14] Fix | Delete
from cproc import Proc
[15] Fix | Delete
import rads
[16] Fix | Delete
[17] Fix | Delete
[18] Fix | Delete
class Config(ConfigParser):
[19] Fix | Delete
"""Parses autoterminate.cfg"""
[20] Fix | Delete
[21] Fix | Delete
def __init__(self):
[22] Fix | Delete
super().__init__(allow_no_value=False)
[23] Fix | Delete
config_file = '/opt/maint/etc/autoterminate.cfg'
[24] Fix | Delete
if not self.read(config_file):
[25] Fix | Delete
raise FileNotFoundError(config_file)
[26] Fix | Delete
[27] Fix | Delete
@cached_property
[28] Fix | Delete
def terminations(self) -> dict[str, int]:
[29] Fix | Delete
"""How many days an account should be suspended before termination.
[30] Fix | Delete
A value of 0 will disable the termination"""
[31] Fix | Delete
return {k: int(v) for k, v in CONF.items('terminations')}
[32] Fix | Delete
[33] Fix | Delete
[34] Fix | Delete
class UserInfo:
[35] Fix | Delete
"""Collects user info from whmapi1 accountsummary"""
[36] Fix | Delete
[37] Fix | Delete
user: str
[38] Fix | Delete
owner: str
[39] Fix | Delete
is_suspended: bool
[40] Fix | Delete
suspend_comment: str
[41] Fix | Delete
days_suspended: int
[42] Fix | Delete
is_reseller: bool
[43] Fix | Delete
children: list[str]
[44] Fix | Delete
keep_dns: Literal[0, 1]
[45] Fix | Delete
[46] Fix | Delete
def __init__(self, user: str):
[47] Fix | Delete
self.user = user
[48] Fix | Delete
acct = whmapi1('accountsummary', {'user': user}, check=True)
[49] Fix | Delete
acct: dict = acct['data']['acct'][0]
[50] Fix | Delete
self.owner = acct['owner']
[51] Fix | Delete
self.is_suspended = bool(acct['suspended'])
[52] Fix | Delete
if not self.is_suspended:
[53] Fix | Delete
Path('/var/cpanel/suspended', user).unlink(missing_ok=True)
[54] Fix | Delete
self.is_reseller = user in ALL_RESELLERS
[55] Fix | Delete
if self.is_reseller:
[56] Fix | Delete
self.children = [
[57] Fix | Delete
user for user, owner in USER_OWNERS.items() if owner == user
[58] Fix | Delete
]
[59] Fix | Delete
else:
[60] Fix | Delete
self.children = []
[61] Fix | Delete
if self.is_suspended:
[62] Fix | Delete
self.suspend_comment = acct.get('suspendreason', '')
[63] Fix | Delete
mtime = Path('/var/cpanel/suspended', user).stat().st_mtime
[64] Fix | Delete
secs_in_day = 86400
[65] Fix | Delete
self.days_suspended = int((time.time() - mtime) / secs_in_day)
[66] Fix | Delete
else:
[67] Fix | Delete
self.suspend_comment = ''
[68] Fix | Delete
self.days_suspended = 0
[69] Fix | Delete
self.keep_dns = int(bool(re.match(r'moved?:', self.suspend_comment)))
[70] Fix | Delete
[71] Fix | Delete
def __repr__(self) -> str:
[72] Fix | Delete
return (
[73] Fix | Delete
f"{self.user} ({self.suspend_comment}, "
[74] Fix | Delete
f"suspended {self.days_suspended} days)"
[75] Fix | Delete
)
[76] Fix | Delete
[77] Fix | Delete
@cached_property
[78] Fix | Delete
def reason(self) -> str:
[79] Fix | Delete
short_reason = self.suspend_comment.split(':', maxsplit=1)[0]
[80] Fix | Delete
if short_reason not in SUSPEND_REASONS:
[81] Fix | Delete
# The user may have been suspended manually or via PP.
[82] Fix | Delete
# For legacy support, try to figure it out
[83] Fix | Delete
for this_reason, regex in SUSPEND_REASONS.items():
[84] Fix | Delete
if regex.search(self.suspend_comment):
[85] Fix | Delete
short_reason = this_reason
[86] Fix | Delete
break
[87] Fix | Delete
if short_reason not in SUSPEND_REASONS:
[88] Fix | Delete
# We don't know why the account was suspended
[89] Fix | Delete
short_reason = 'other'
[90] Fix | Delete
return short_reason
[91] Fix | Delete
[92] Fix | Delete
@property
[93] Fix | Delete
def can_terminate(self):
[94] Fix | Delete
"""Evaluates whether a user meets the criteria for termination"""
[95] Fix | Delete
if not self.is_suspended:
[96] Fix | Delete
return False
[97] Fix | Delete
reason = self.reason
[98] Fix | Delete
# Has the account been suspended long enough?
[99] Fix | Delete
try:
[100] Fix | Delete
days_needed = CONF.terminations[reason]
[101] Fix | Delete
except KeyError:
[102] Fix | Delete
LOGGER.warning(
[103] Fix | Delete
"%s - term length not defined for reason %r", self.user, reason
[104] Fix | Delete
)
[105] Fix | Delete
return False # terms not defined for this reason
[106] Fix | Delete
if days_needed <= 0:
[107] Fix | Delete
LOGGER.debug("%s - terms disabled for %r", self.user, reason)
[108] Fix | Delete
return False # terms disabled in config
[109] Fix | Delete
if self.days_suspended < days_needed:
[110] Fix | Delete
LOGGER.debug(
[111] Fix | Delete
"%s - not ready for term (suspended %d/%d days)",
[112] Fix | Delete
self.user,
[113] Fix | Delete
self.days_suspended,
[114] Fix | Delete
days_needed,
[115] Fix | Delete
)
[116] Fix | Delete
return False
[117] Fix | Delete
return True
[118] Fix | Delete
[119] Fix | Delete
[120] Fix | Delete
def set_pp_status_reclaimed(user: str):
[121] Fix | Delete
"""Notify PowerPanel that the user has been terminated"""
[122] Fix | Delete
amp = PowerPanel()
[123] Fix | Delete
results = amp.call(
[124] Fix | Delete
'hosting-server.get-status', username=user, machine=MACHINE
[125] Fix | Delete
)
[126] Fix | Delete
for row in results.data:
[127] Fix | Delete
if row['status'] == "approved" or row['status'] == "suspended":
[128] Fix | Delete
set_status = amp.call(
[129] Fix | Delete
'hosting-server.set-status',
[130] Fix | Delete
username=user,
[131] Fix | Delete
machine=MACHINE,
[132] Fix | Delete
status='reclaimed',
[133] Fix | Delete
id=row['id'],
[134] Fix | Delete
)
[135] Fix | Delete
LOGGER.info(
[136] Fix | Delete
"PowerPanel reclamation status: %s (%s)",
[137] Fix | Delete
set_status.status,
[138] Fix | Delete
set_status.message,
[139] Fix | Delete
)
[140] Fix | Delete
if set_status.status != 200:
[141] Fix | Delete
LOGGER.warning("PowerPanel reclamation failed!")
[142] Fix | Delete
[143] Fix | Delete
[144] Fix | Delete
def terminate_user(dryrun: bool, user: str, keep_dns: Literal[0, 1]) -> bool:
[145] Fix | Delete
"""Handles user termination"""
[146] Fix | Delete
path = Path('/var/cpanel/suspended', user)
[147] Fix | Delete
if dryrun or 'donotterm' in path.read_text('utf-8').lower():
[148] Fix | Delete
return False
[149] Fix | Delete
try:
[150] Fix | Delete
homedir = rads.get_homedir(user)
[151] Fix | Delete
Proc.run(
[152] Fix | Delete
['ionice', '-c2', '-n7', 'rm', '-rf', homedir],
[153] Fix | Delete
lim=os.cpu_count(),
[154] Fix | Delete
check=False,
[155] Fix | Delete
encoding=None,
[156] Fix | Delete
capture_output=True, # adds output to exception if raised
[157] Fix | Delete
)
[158] Fix | Delete
whmapi1('removeacct', {'user': user, 'keepdns': keep_dns}, check=True)
[159] Fix | Delete
except Exception as exc:
[160] Fix | Delete
LOGGER.error('%s - %s: %s', user, type(exc).__name__, exc)
[161] Fix | Delete
send_ticket(
[162] Fix | Delete
dryrun,
[163] Fix | Delete
user,
[164] Fix | Delete
f"auto_terminate encountered an error trying to terminate {user}.\n"
[165] Fix | Delete
f"{type(exc).__name__}: {exc}\n"
[166] Fix | Delete
"Please check on this account and removeacct it if needed.",
[167] Fix | Delete
)
[168] Fix | Delete
return False
[169] Fix | Delete
return True
[170] Fix | Delete
[171] Fix | Delete
[172] Fix | Delete
def send_ticket(dryrun: bool, user: str, message: str):
[173] Fix | Delete
"""Sends a reclamation request"""
[174] Fix | Delete
LOGGER.warning('Creating reclamations ticket for %s', user)
[175] Fix | Delete
if dryrun:
[176] Fix | Delete
return
[177] Fix | Delete
rads.send_email(
[178] Fix | Delete
to_addr="reclamations@imhadmin.net",
[179] Fix | Delete
subject=f"[{MACHINE}] Please review/terminate user {user}",
[180] Fix | Delete
body=message,
[181] Fix | Delete
)
[182] Fix | Delete
[183] Fix | Delete
[184] Fix | Delete
def local_dns(ip_list: list[str], user: str) -> str:
[185] Fix | Delete
"""Checks to see if any domains in a user account are pointed locally"""
[186] Fix | Delete
try:
[187] Fix | Delete
data = rads.UserData(user)
[188] Fix | Delete
except rads.CpuserError as exc:
[189] Fix | Delete
LOGGER.warning('%s - %s', user, exc)
[190] Fix | Delete
return ""
[191] Fix | Delete
domains = [data.primary.domain]
[192] Fix | Delete
domains.extend([x.domain for x in data.parked])
[193] Fix | Delete
domains.extend([x.domain for x in data.addons])
[194] Fix | Delete
local = []
[195] Fix | Delete
[196] Fix | Delete
for domain in domains:
[197] Fix | Delete
try:
[198] Fix | Delete
addr = socket.gethostbyname(domain)
[199] Fix | Delete
except OSError:
[200] Fix | Delete
continue
[201] Fix | Delete
if addr in ip_list:
[202] Fix | Delete
local.append(domain)
[203] Fix | Delete
return '\n'.join(local)
[204] Fix | Delete
[205] Fix | Delete
[206] Fix | Delete
def parse_args():
[207] Fix | Delete
"""Parse sys.argv"""
[208] Fix | Delete
parser = ArgumentParser(description=__doc__)
[209] Fix | Delete
# fmt: off
[210] Fix | Delete
parser.add_argument(
[211] Fix | Delete
'-d', '--dryrun', action='store_true',
[212] Fix | Delete
help='Test mode - Do not terminate any accounts or create tickets',
[213] Fix | Delete
)
[214] Fix | Delete
# fmt: on
[215] Fix | Delete
return parser.parse_args()
[216] Fix | Delete
[217] Fix | Delete
[218] Fix | Delete
def valid_user(user: str) -> bool:
[219] Fix | Delete
"""Used to filter /var/cpanel/suspended to users we may take action on"""
[220] Fix | Delete
if user.endswith('.lock') or user in rads.OUR_RESELLERS:
[221] Fix | Delete
return False
[222] Fix | Delete
try:
[223] Fix | Delete
owner = USER_OWNERS[user]
[224] Fix | Delete
except KeyError: # user does not exist
[225] Fix | Delete
assert not user.startswith('..') and not user.startswith('/')
[226] Fix | Delete
Path('/var/cpanel/suspended', user).unlink(missing_ok=True)
[227] Fix | Delete
return False
[228] Fix | Delete
if rads.IMH_CLASS == 'reseller':
[229] Fix | Delete
if owner not in rads.OUR_RESELLERS:
[230] Fix | Delete
return False
[231] Fix | Delete
if user not in ALL_RESELLERS:
[232] Fix | Delete
LOGGER.warning('%s may be an orphaned account', user)
[233] Fix | Delete
return False
[234] Fix | Delete
return True
[235] Fix | Delete
[236] Fix | Delete
[237] Fix | Delete
def iter_ips():
[238] Fix | Delete
"""Iterate system IPs"""
[239] Fix | Delete
with open('/etc/ips', encoding='utf-8') as file:
[240] Fix | Delete
for line in file:
[241] Fix | Delete
yield line.split(':', maxsplit=1)[0].strip()
[242] Fix | Delete
with open('/var/cpanel/mainip', encoding='utf-8') as file:
[243] Fix | Delete
yield file.read().strip()
[244] Fix | Delete
[245] Fix | Delete
[246] Fix | Delete
def main():
[247] Fix | Delete
dryrun: bool = parse_args().dryrun
[248] Fix | Delete
if dryrun:
[249] Fix | Delete
LOGGER.info('Starting next run with --dryrun')
[250] Fix | Delete
else:
[251] Fix | Delete
LOGGER.info('Starting next run')
[252] Fix | Delete
APACHE_NO_RESTART.touch(mode=0o644, exist_ok=True)
[253] Fix | Delete
ip_list = list(filter(None, iter_ips()))
[254] Fix | Delete
for user in filter(valid_user, os.listdir('/var/cpanel/suspended')):
[255] Fix | Delete
try:
[256] Fix | Delete
data = UserInfo(user)
[257] Fix | Delete
except Exception as exc:
[258] Fix | Delete
LOGGER.error('%s - %s: %s', user, type(exc).__name__, exc)
[259] Fix | Delete
continue
[260] Fix | Delete
if not data.can_terminate:
[261] Fix | Delete
continue
[262] Fix | Delete
if data.reason not in ('billing', 'canceled', 'ra', 'tos'):
[263] Fix | Delete
if local := local_dns(ip_list, user):
[264] Fix | Delete
LOGGER.warning(
[265] Fix | Delete
"%s - domains are still pointed to this server", user
[266] Fix | Delete
)
[267] Fix | Delete
send_ticket(
[268] Fix | Delete
dryrun,
[269] Fix | Delete
user,
[270] Fix | Delete
f"Cannot terminate {user} - domain(s) are still pointed to "
[271] Fix | Delete
f"this server:\n\n{local}",
[272] Fix | Delete
)
[273] Fix | Delete
continue
[274] Fix | Delete
if rads.IMH_CLASS == 'reseller':
[275] Fix | Delete
# If this is a reseller, terminate their child accounts first
[276] Fix | Delete
for child in data.children:
[277] Fix | Delete
LOGGER.info("Terminating sub-user %s (owner: %s)", child, user)
[278] Fix | Delete
terminate_user(dryrun, user, data.keep_dns)
[279] Fix | Delete
LOGGER.info("Terminating user %r", data)
[280] Fix | Delete
terminate_user(dryrun, user, data.keep_dns)
[281] Fix | Delete
# Set account status to 'reclaimed' in PowerPanel if not 'moved'
[282] Fix | Delete
# keep_dns is 1 if "moved"
[283] Fix | Delete
if not data.keep_dns:
[284] Fix | Delete
set_pp_status_reclaimed(user)
[285] Fix | Delete
# Make sure apache will restart normally again
[286] Fix | Delete
APACHE_NO_RESTART.unlink(missing_ok=True)
[287] Fix | Delete
[288] Fix | Delete
[289] Fix | Delete
if __name__ == '__main__':
[290] Fix | Delete
CONF = Config()
[291] Fix | Delete
MACHINE = platform.node().split('.', maxsplit=1)[0]
[292] Fix | Delete
# Ref: https://confluence1.cpanel.net/display/EA/Flag+Files
[293] Fix | Delete
APACHE_NO_RESTART = Path('/var/cpanel/mgmt_queue/apache_update_no_restart')
[294] Fix | Delete
ALL_RESELLERS = whmapi1.listresellers()
[295] Fix | Delete
USER_OWNERS = rads.all_cpusers(owners=True)
[296] Fix | Delete
SUSPEND_REASONS = {
[297] Fix | Delete
'ra': re.compile('ra', flags=re.IGNORECASE),
[298] Fix | Delete
'tos': re.compile('tos', flags=re.IGNORECASE),
[299] Fix | Delete
'billing': re.compile(
[300] Fix | Delete
r'active queue|suspension queue|billing|\[PP2 [A-Za-z]+\]',
[301] Fix | Delete
flags=re.IGNORECASE,
[302] Fix | Delete
),
[303] Fix | Delete
'legal': re.compile('legal', flags=re.IGNORECASE),
[304] Fix | Delete
'donotterm': re.compile('donotterm', flags=re.IGNORECASE),
[305] Fix | Delete
'chargeback': re.compile('chargeback', flags=re.IGNORECASE),
[306] Fix | Delete
'canceled': re.compile(r'cancel\|refund', flags=re.IGNORECASE),
[307] Fix | Delete
'moved': re.compile(
[308] Fix | Delete
r'move|\[PP2 [A-Za-z]+\] - Reason: Account[ ]*Consolidation',
[309] Fix | Delete
flags=re.IGNORECASE,
[310] Fix | Delete
),
[311] Fix | Delete
}
[312] Fix | Delete
# cron config appends stdout/err to /var/log/maint/auto_terminate.log
[313] Fix | Delete
LOGGER = rads.setup_logging(
[314] Fix | Delete
path=None, name='auto_terminate', loglevel='DEBUG', print_out='stdout'
[315] Fix | Delete
)
[316] Fix | Delete
try:
[317] Fix | Delete
with rads.lock('auto_terminate'):
[318] Fix | Delete
main()
[319] Fix | Delete
except rads.LockError:
[320] Fix | Delete
LOGGER.critical('Another instance is already running. Exiting.')
[321] Fix | Delete
[322] Fix | Delete
It is recommended that you Edit text format, this type of Fix handles quite a lot in one request
Function