Mini Shell

Direktori : /proc/self/root/opt/maint/bin/
Upload File :
Current File : //proc/self/root/opt/maint/bin/audit_resellers.py

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