Mini Shell
#!/opt/imh-python/bin/python3
"""
Reseller audit script. Does the following:
1) Makes sure that all resellers are owned by 'inmotion' or 'hubhost'
2) Resets reseller ACL limits and IP pools
3) Checks for orphaned accounts (accounts that have a non-existent owner)
"""
from collections import defaultdict
import configparser
import argparse
import logging
import platform
import sys
import time
import pwd
from pathlib import Path
from typing import Union
import yaml
import rads
from cpapis import whmapi1, CpAPIError
APIPA = '169.254.100.100' # the old moveuser used this for reseller moves
HOST = platform.node().split('.')[0]
RESELLER = 'hubhost' if rads.IMH_CLASS == 'hub' else 'inmotion'
def parse_args() -> tuple[int, bool]:
"""Parse sys.argv"""
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
'--loglevel',
'-l',
default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
)
parser.add_argument(
'--noop',
'--dry-run',
'-n',
dest='noop',
action='store_true',
help="Make no changes",
)
args = parser.parse_args()
loglevel = getattr(logging, args.loglevel)
return loglevel, args.noop
def get_dips() -> dict[str, set[str]]:
"""Get a mapping of ipaddr -> resellers from /var/cpanel/dips"""
dips = defaultdict(set)
try:
for res_path in Path('/var/cpanel/dips').iterdir():
try:
res_ips = set(res_path.read_text('ascii').split())
except OSError:
continue
try:
res_ips.remove(APIPA)
except KeyError:
pass
for ipaddr in res_ips:
dips[ipaddr].add(res_path.name)
except FileNotFoundError:
pass
return dict(dips)
def check_double_ip_delegations(resellers: set[str], noop: bool):
"""Check for IPs which are assigned to more than one reseller"""
double_delegations = {
ipaddr: resellers
for ipaddr, resellers in get_dips().items()
if len(resellers) > 1
}
if double_delegations:
auto_fix_double_dips(resellers, double_delegations, noop)
if not double_delegations:
return
logging.warning("Double-delegated IP addresses detected - sending ticket")
logging.debug('double delegations: %r', double_delegations)
if noop:
return
body = (
"The following IP addresses were detected as being delegated to "
"more than one reseller and must be corrected:\n"
)
for ip_addr, res in double_delegations.items():
body = f"{body}\n{ip_addr}: {', '.join(res)}"
rads.send_email(
to_addr="str@imhadmin.net",
subject="Reseller IP delegation conflict",
body=body,
)
def auto_fix_double_dips(
resellers: set[str], double_delegations: dict[str, set[str]], noop: bool
):
"""Attempt to automatically fix IP double-delegations by checking if the IP
is actually in use, and removing it from resellers which aren't using it"""
user_ips: dict[str, str] = yaml.load(
Path('/etc/userips').read_text('ascii'), rads.DumbYamlLoader
)
user_resellers: dict[str, str] = yaml.load(
Path('/etc/trueuserowners').read_text('ascii'), rads.DumbYamlLoader
)
user_resellers = {
k: k if k in resellers else v for k, v in user_resellers.items()
}
for ipaddr, res in double_delegations.copy().items():
if res.intersection(rads.OUR_RESELLERS):
# if there's a conflict involving one of our resellers, don't try
# to auto-fix it
continue
# collect resellers actually using the IP
using = list(
{user_resellers[k] for k, v in user_ips.items() if v == ipaddr}
)
if len(using) > 1:
continue # legit conflict. don't auto-fix
if len(using) == 0:
# No one is using this IP. Take it away from all but one reseller.
# If this takes away any reseller's last IP, the next run of this
# cron should fix it.
for remove in list(res)[1:]:
remove_dip(ipaddr, remove, double_delegations, noop)
elif using[0] in res:
# else one reseller is using it but it's delegated to multiple
for remove in list(res):
if remove != using[0]:
remove_dip(ipaddr, remove, double_delegations, noop)
def remove_dip(
ipaddr: str,
reseller: str,
double_delegations: dict[str, set[str]],
noop: bool,
) -> None:
"""Remove an IP from a reseller's pool to fix a double delegation"""
# make sure it wasn't their main. the calling function already checked that
# the reseller didn't have it assigned
main_ip = Path('/var/cpanel/mainips', reseller).read_text('ascii').strip()
if main_ip == ipaddr:
return
logging.warning("removing %s from %s's IP pool", ipaddr, reseller)
pool = whmapi1.getresellerips(reseller)['ip']
try:
pool.remove(ipaddr)
except ValueError:
# but the previous lookup had it?
logging.error("Could not remove %s from %s's IP pool", ipaddr, reseller)
return
if not noop:
try:
whmapi1.setresellerips(reseller, pool, delegate=True)
except CpAPIError as exc:
logging.error(
"Could not remove %s from %s's IP pool: %s",
ipaddr,
reseller,
exc,
)
return
double_delegations[ipaddr].remove(reseller)
if len(double_delegations[ipaddr]) < 2:
double_delegations.pop(ipaddr)
class CpanelConf(configparser.ConfigParser):
"""Handles reading /var/cpanel/users and /var/cpanel/packages files"""
def __init__(self, path: Path):
super().__init__(allow_no_value=True, interpolation=None, strict=False)
try:
self.read_string(f"[config]\n{path.read_text('utf-8')}")
except Exception as exc:
logging.error('%s - %s: %s', path, type(exc).__name__, exc)
raise
@classmethod
def user_conf(cls, user: str):
"""Read /var/cpanel/users/{user}"""
return cls(Path('/var/cpanel/users', user))
@classmethod
def pkg_conf(cls, pkg: str):
"""Read /var/cpanel/packages/{pkg}"""
return cls(Path('/var/cpanel/packages', pkg))
@property
def res_limits(self) -> dict[str, str]:
"""Read imh custom reseller limits from a cPanel package
(use only with pkg_conf)"""
imh_keys = (
'account_limit',
'bandwidth_limit',
'diskspace_limit',
'enable_account_limit',
'enable_resource_limits',
'enable_overselling',
'enable_overselling_bandwidth',
'enable_overselling_diskspace',
)
return {
x: self.get('config', f'imh_{x}', fallback='') for x in imh_keys
}
def get_main_ips() -> set[str]:
"""Collect IPs from /var/cpanel/mainip and /var/cpanel/mainips/root"""
with open('/var/cpanel/mainip', encoding='ascii') as ip_file:
ips = set(ip_file.read().split())
try:
with open('/var/cpanel/mainips/root', encoding='ascii') as ip_file:
ips.update(ip_file.read().split())
except FileNotFoundError:
pass
return ips
def get_new_ip() -> str:
"""Get an IP which is not already in use"""
with open('/etc/ipaddrpool', encoding='ascii') as pool:
# not assigned as dedicated, but may be in a reseller pool
unassigned = pool.read().split()
for ip_addr in unassigned:
if not assigned_to_res(ip_addr):
return ip_addr
return ''
def assigned_to_res(ip_addr):
"""Determine if an IP is already delegated to a reseller"""
for entry in Path('/var/cpanel/dips').iterdir():
with entry.open('r', encoding='ascii') as dips:
if ip_addr in dips.read().split():
return True
return False
def non_res_checks(noop: bool):
"""Reseller-owner checks on non-reseller servers"""
for path in Path('/var/cpanel/users').iterdir():
user = path.name
if user == 'root':
logging.warning('%s exists. Skipping.', path)
continue
if user in rads.OUR_RESELLERS:
try:
whmapi1.set_owner(user, 'root')
except CpAPIError as exc:
logging.error(
"Error changing owner of %s to root: %s", user, exc
)
continue
try:
user_conf = CpanelConf.user_conf(user)
except Exception:
continue
try:
owner = user_conf.get('config', 'owner')
except configparser.NoOptionError:
logging.warning(
'%s is missing OWNER and may not be a valid CPanel user file',
path,
)
continue
if owner != RESELLER:
set_owner(user, owner, RESELLER, noop)
def get_resellers() -> set[str]:
"""Read resellers from /var/cpanel/resellers"""
resellers = set()
with open('/var/cpanel/resellers', encoding='utf-8') as res_file:
for line in res_file:
if res := line.split(':', maxsplit=1)[0]:
resellers.add(res)
return resellers
def main():
"""Cron main"""
loglevel, noop = parse_args()
if noop:
logfmt = '%(asctime)s %(levelname)s NOOP %(message)s'
else:
logfmt = '%(asctime)s %(levelname)s %(message)s'
rads.setup_logging(
path=None, loglevel=loglevel, fmt=logfmt, print_out=sys.stdout
)
if rads.IMH_ROLE != 'shared':
logging.critical("rads.IMH_CLASS=%r", rads.IMH_ROLE)
sys.exit(1)
if 'res' in HOST and rads.IMH_CLASS != 'reseller':
logging.critical(
"hostname=%r but rads.IMH_CLASS=%r", HOST, rads.IMH_CLASS
)
sys.exit(1)
resellers = get_resellers()
all_res = resellers | set(rads.OUR_RESELLERS) | {"system", rads.SECURE_USER}
if rads.IMH_CLASS == 'reseller':
main_ips = get_main_ips()
for reseller in resellers:
res_checks(reseller, main_ips, noop)
orphan_storage = defaultdict(list)
term_fails = defaultdict(list)
for entry in Path("/var/cpanel/users").iterdir():
user = entry.name
if user in all_res:
continue
try:
pwd.getpwnam(user)
except KeyError:
logging.warning("Removing erroneous file at %s", entry)
if not noop:
entry.unlink()
continue
check_orphans(user, main_ips, orphan_storage, term_fails, noop)
for reseller, orphans in orphan_storage.items():
orphans_notify(reseller, orphans, noop)
for reseller, orphans in term_fails.items():
term_fail_notice(reseller, orphans, noop)
else:
non_res_checks(noop)
cleanup_delegations(all_res, noop)
check_double_ip_delegations(resellers, noop)
def cleanup_delegations(all_res: set[str], noop: bool):
"""Remove /var/cpanel/dips (ip delegation) files for deleted resellers"""
for entry in Path('/var/cpanel/dips').iterdir():
if entry.name not in all_res:
logging.debug('deleting %s', entry)
if not noop:
entry.unlink()
def check_orphans(
user: str,
main_ips: set[str],
orphan_storage: defaultdict[list],
term_fails: defaultdict[list],
noop: bool,
):
"""Find orphaned accounts (accounts that have no existing owner)"""
try:
user_conf = CpanelConf.user_conf(user)
except Exception:
return
owner = user_conf.get('config', 'owner', fallback=None)
if not owner:
return
ip_address = user_conf.get('config', 'ip', fallback=None)
if (
not Path('/var/cpanel/users', owner).exists()
or owner in rads.OUR_RESELLERS
):
# this is an orphaned account
try:
susp_time = Path('/var/cpanel/suspended', user).stat().st_mtime
except FileNotFoundError:
# the orphaned account is not suspended
orphan_storage[owner].append(user)
return
# If the orphan is suspended for more than 14 days, terminate it
if time.time() - susp_time > 14 * 86400:
logging.info("Terminating suspended orphan user %s", user)
if noop:
return
try:
whmapi1.removeacct(user, keepdns=False)
except CpAPIError as exc:
logging.warning("Failed to terminate user %s: %s", user, exc)
term_fails[owner].append(user)
else:
logging.debug(
"Orphaned user %s has not been suspended long "
"enough for auto-terminate",
user,
)
return
# This is a non-orphaned, child account.
# While we're here, make sure the user's IP is correct.
if not ip_address or ip_address in main_ips:
# Assign the user their owner's IP
set_child_owner_ip(user, owner, noop)
def orphans_notify(reseller: str, orphans: list[str], noop: bool) -> None:
"""Notify for unsuspended orphan accounts"""
logging.warning(
'%s orphaned accounts exist under the reseller %s. Sending STR.',
len(orphans),
reseller,
)
logging.debug('Orphans under %s: %r', reseller, orphans)
if noop:
return
str_body = f"""
The following orphan accounts have been located under owner {reseller}:
{' '.join(orphans)}
They appear to have an owner that does not exist, or is a reseller missing
reseller privileges. If the orphan's owner exists in PowerPanel, please set
their owner to 'inmotion' or 'hubhost' as appropriate. If the orphan's owner is
a reseller, add reseller privileges. If the orphan account does not exist,
please suspend them on the server with the command
"for orphan in {' '.join(orphans)}; do suspend_user $orphan -r orphan; done"
Thank you,
{HOST}"""
rads.send_email(
to_addr="str@imhadmin.net",
subject=f"Orphan accounts on {HOST} with owner {reseller}",
body=str_body,
)
def term_fail_notice(reseller: str, orphans: list[str], noop: bool) -> None:
"""Separate notification for orphans that failed to auto-term, because
suspending them again won't fix the problem"""
logging.warning(
"%s orphaned accounts failed to auto-terminate under the reseller %s. "
"Sending STR.",
len(orphans),
reseller,
)
logging.debug("terms failed for %r", orphans)
if noop:
return
str_body = f"""
The following orphan accounts were found under owner {reseller} and were
suspended long enough to auto-terminate, but auto-termination failed:
{' '.join(orphans)}
Please investigate and if appropriate, run removeacct on the orphan accounts.
Thank you,
{HOST}"""
rads.send_email(
to_addr="str@imhadmin.net",
subject=f"Failed to auto-term orphans on {HOST} with owner {reseller}",
body=str_body,
)
def set_child_owner_ip(user: str, owner: str, noop: bool) -> None:
"""Assign the user their owner's IP"""
try:
owner_conf = CpanelConf.user_conf(owner)
except Exception:
owner_ipaddr = None
else:
owner_ipaddr = owner_conf.get('config', 'ip')
if not owner_ipaddr:
logging.error(
"User %s has shared IP, but couldn't determine the IP of "
"the owner %s to assign it to the child account",
user,
owner,
)
return
logging.warning(
"User %s has shared IP. Changing to owner %s's IP of %s",
user,
owner,
owner_ipaddr,
)
if noop:
return
try:
whmapi1.setsiteip(user, owner_ipaddr)
except CpAPIError as exc:
logging.error(
"Error changing IP of %s to %s: %s", user, owner_ipaddr, exc
)
def set_owner(user: str, old: str, new: str, noop: bool):
"""Change user owner and log"""
logging.info("Changing ownership of %s from %s to %s", user, old, new)
if noop:
return
try:
whmapi1.set_owner(user, new)
except CpAPIError as exc:
logging.error(
"Error changing ownership of %s to %s: %s", user, new, exc
)
def res_checks(user: str, main_ips: set[str], noop: bool):
"""All reseller and IP checks for res servers"""
try:
user_conf = CpanelConf.user_conf(user)
except Exception:
return
if Path('/var/cpanel/suspended', user).exists():
return
if user not in rads.OUR_RESELLERS:
owner_needed = RESELLER
# 1) Reset the reseller ACL to match the package name
if pkg := set_reseller_acl(user, user_conf, noop):
try:
package_conf = CpanelConf.pkg_conf(pkg)
except Exception:
return
# 2) Reset the reseller's resource limits
set_reseller_resource_limits(user, package_conf, noop)
else:
owner_needed = 'root'
# 3) Make sure the reseller itself is owned by the correct user
owner = user_conf.get('config', 'owner', fallback=None)
if owner != owner_needed:
set_owner(user, owner, owner_needed, noop)
if user not in rads.OUR_RESELLERS:
setup_dips(user, user_conf, main_ips, noop)
def setup_dips(
user: str, user_conf: CpanelConf, main_ips: set[str], noop: bool
):
"""Create a dedicated IP pool for resellers that don't have one"""
# This is necessary to prevent resellers from having access to assign all
# IPs on a server
ipaddr = user_conf.get('config', 'ip', fallback='')
if not ipaddr or ipaddr in main_ips:
# Assign the user a new IP ###
if ipaddr := get_new_ip():
logging.info("Assigning reseller %s its own IP %s", user, ipaddr)
if not noop:
try:
whmapi1.setsiteip(user, ipaddr)
except CpAPIError as exc:
logging.error(
"Error changing IP of %s to %s: %s", user, ipaddr, exc
)
else:
logging.error("Could not find an unused IP to assign to %s", user)
return
set_reseller_mainip(user, ipaddr, noop)
# check if user has a dedicated ip pool
if Path(f'/var/cpanel/dips/{user}').exists():
current = set(whmapi1.getresellerips(user)['ip'])
pool = {x for x in current if x not in main_ips}
pool.add(ipaddr)
# whmapi1 getresellerips returns all free ips if no delegation exists
else:
current = None
pool = {ipaddr}
if current != pool:
logging.info(
"Changing IP delegation for %s from %r to %r", user, current, pool
)
if noop:
return
try:
whmapi1.setresellerips(user, pool, delegate=True)
except CpAPIError as exc:
logging.error(
"Error changing IP delegation for %s to %r: %s", user, pool, exc
)
def set_reseller_acl(
user: str, user_conf: CpanelConf, noop: bool
) -> Union[str, None]:
"""Reset the reseller ACL to match the package name"""
pkg = user_conf.get('config', 'plan', fallback=None)
if not pkg or not Path('/var/cpanel/acllists', pkg).exists():
# This means the reseller is set to a plan that likely isn't configured
# on the server. If this is the case, strip their ACL (just to be safe)
pkg = None
logging.debug("Setting reseller %s to ACL %r", user, pkg)
if noop:
return pkg
try:
whmapi1.set_acllist(user, pkg)
except CpAPIError as exc:
logging.error("Error setting %s to ACL %s: %s", user, pkg, exc)
return pkg
def set_reseller_mainip(user: str, ipaddr: str, noop: bool):
"""Call setresellermainip if needed"""
try:
current = Path('/var/cpanel/mainips', user).read_text('ascii').strip()
except OSError:
current = None
if current == ipaddr:
return
logging.info('Setting main IP for %s to %s', user, ipaddr)
if noop:
return
try:
whmapi1(
'setresellermainip', args={'user': user, 'ip': ipaddr}, check=True
)
except CpAPIError as exc:
logging.error(
"Could not set main IP for %s to %s: %s", user, ipaddr, exc
)
def set_reseller_resource_limits(
user: str, package_conf: CpanelConf, noop: bool
) -> None:
"""Reset the reseller's resource limits"""
limit_kwargs = package_conf.res_limits.copy()
if limit_kwargs['enable_resource_limits'] == '1':
logging.debug(
"Setting reseller limits for %s to %r", user, limit_kwargs
)
limit_kwargs['user'] = user
if noop:
return
try:
whmapi1('setresellerlimits', args=limit_kwargs, check=True)
except CpAPIError as exc:
logging.error("Error setting reseller limits for %s: %s", user, exc)
if __name__ == '__main__':
main()
Zerion Mini Shell 1.0