Mini Shell

Direktori : /opt/sharedrads/
Upload File :
Current File : //opt/sharedrads/update_spf

#!/opt/imh-python/bin/python3
"""SPF record updater"""
# Vanessa Vasile, InMotion Hosting, Oct 2017
# ref: https://trac.imhtech.net/T3/ticket/18096

from argparse import ArgumentParser, Action, ArgumentTypeError as BadArg
import platform
import sys
from typing import Literal, Union
from subprocess import CalledProcessError, check_output
from cpapis import whmapi1, cpapi2, CpAPIError
from rads import cpuser_safe
from rads.color import red, yellow, green, magenta, blue
from collections import OrderedDict

HOSTNAME = platform.node()
SPAMEXP_HOSTNAME = 'smtp.servconfig.com'


def get_args() -> tuple[
    Literal['create', 'update', 'delete'], bool, str, list[str]
]:
    """Arg Parser"""
    parser = ArgumentParser(description='Configures SPF records')
    actions_group = parser.add_mutually_exclusive_group(required=True)
    targets_group = parser.add_mutually_exclusive_group(required=True)
    # fmt: off
    actions_group.add_argument(
        '--create', '-c', action='store_const', const='create', dest='action',
        help='Creates a new SPF record and applies it. '
        'Will overwrite an existing one.',
    )
    actions_group.add_argument(
        '--update', '-r', action='store_const', const='update', dest='action',
        help='Updates the existing SPF record to be correct',
    )
    parser.add_argument(
        '--show-only', '-s', action='store_true',
        help='Do not apply the new record, just show it',
    )
    actions_group.add_argument(
        '--delete', '-x', action='store_const', const='delete', dest='action',
        help='Deletes an SPF TXT record',
    )
    targets_group.add_argument(
        '--user', '-u', action=SetUserAction,
        help='Update all domains (main, addon, parked) for this user',
    )
    targets_group.add_argument(
        '--domain', '-d', action=SetDomainAction, dest='domains',
        help='Domain to update',
    )
    # fmt: on
    args = parser.parse_args()
    return args.action, args.show_only, args.user, args.domains


class SetUserAction(Action):
    def __call__(self, parser, namespace, values, option_string=None):
        user: str = values
        if not cpuser_safe(user):
            raise BadArg(f"{user} is not a valid cPanel user or is restricted")
        try:
            api = cpapi2('DomainLookup::getbasedomains', user)
            domains = [x['domain'] for x in api['cpanelresult']['data']]
        except Exception as exc:
            print(type(exc).__name__, exc, file=sys.stderr)
            raise BadArg("Error getting domain list from cpapi2") from exc
        setattr(namespace, 'domains', domains)
        setattr(namespace, 'user', user)


class SetDomainAction(Action):
    def __call__(self, parser, namespace, values, option_string=None):
        domain: str = values
        user = whoowns(domain)
        if not user:
            raise BadArg(f"{domain} is not owned by a user on the system")
        if not cpuser_safe(user):
            raise BadArg(f"{user} is a restricted user")
        setattr(namespace, 'domains', [domain])


def err_msg(msg: str) -> None:
    print(red(msg), file=sys.stderr)


def main():
    action, show_only, user, domains = get_args()
    spf_data: dict[str, tuple[int, str]] = {}
    for domain in domains:
        spf_data.update(get_spf(domain))
    if user is None and not spf_data and action != 'create':
        # if user is None, --domain / -d was used
        err_msg(
            f"{domains[0]} does not have an SPF record. "
            "Use --create/-c instead"
        )
        sys.exit(1)
    mail_ips, domain_mail_ips = get_mail_ips(list(spf_data.keys()))
    if action == 'update':
        for domain, (line_num, txtdata) in spf_data.items():
            new_record = generate_spf(
                existing_spf=txtdata,
                mail_ips=mail_ips,
                domain=domain,
                domain_ip=domain_mail_ips.get(domain, None),
                overwrite=False,
            )
            print(blue(f"{domain}:"))
            print(magenta(f'\tExisting: "{txtdata}"'))
            if show_only:
                print(yellow(f'\tNew: "{new_record}"'))
                continue
            print(green(f'\tNew: "{new_record}"'))
            update_zone(domain, domain, line_num, new_record)
    elif action == 'create':
        for domain in domains:
            line_num, txtdata = spf_data.get(domain, (None, None))
            new_record = generate_spf(
                existing_spf=None,
                mail_ips=mail_ips,
                domain=domain,
                domain_ip=domain_mail_ips.get(domain, None),
                overwrite=True,
            )
            print(blue(f"{domain}:"))
            print(magenta(f'\tExisting: "{txtdata}"'))
            if show_only:
                print(yellow(f'\tNew: "{new_record}"'))
                continue
            print(green(f'\tNew: "{new_record}"'))
            if txtdata is None:  # add new record
                update_zone(domain, domain, None, new_record)
            else:  # update existing
                update_zone(domain, domain, line_num, new_record)
    elif action == 'delete':
        for domain, (line_num, txtdata) in spf_data.items():
            print(blue(f"{domain}:"))
            print(magenta(f'\tExisting: "{txtdata}"'))
            if show_only:
                continue
            delete_spf(domain, txtdata)
    else:
        raise RuntimeError(f"{action=}")


def get_mail_ips(domains: list[str]) -> tuple[list[str], dict[str, str]]:
    """Pulls relevant data"""
    mail_ips = []
    for path in ('/var/cpanel/mainip', '/var/cpanel/mainips/root'):
        try:
            with open(path, encoding='utf-8') as file:
                for line in file:
                    mail_ips.append(line.strip())
        except OSError:
            pass
    domain_mail_ips = {}
    try:
        with open('/etc/mailips', encoding='utf-8') as file:
            for line in file:
                dom, ipaddr = line.strip().split(':')
                if dom in domains:
                    domain_mail_ips[dom] = ipaddr.strip()
    except ValueError:
        err_msg('/etc/mailips is incorrectly formatted')
        sys.exit(1)
    except OSError:
        pass
    return mail_ips, domain_mail_ips


def whoowns(domain: str) -> str:
    """Obtain the cPanel user owning a domain"""
    try:
        user = check_output(
            ['/usr/local/cpanel/scripts/whoowns', domain], encoding='utf-8'
        )
    except CalledProcessError:
        return ''
    return user.strip()


def get_spf(domain: str) -> dict[str, tuple[int, str]]:
    """Gets the SPF record for a domain"""
    try:
        api = whmapi1('dumpzone', {"domain": domain}, check=True)
    except CpAPIError as exc:
        err_msg(str(exc))
        return {}
    spf_records = {}
    for data in api['data']['zone'][0]['record']:
        data: dict
        record_type: str = data.get('type', '')
        txtdata: str = data.get('txtdata', '')
        if 'TXT' not in record_type or 'v=spf1' not in txtdata:
            continue
        line: int = data['Line']
        name: str = data['name'].rstrip('.')

        # If we have a duplicate key, error out. This is invalid.
        if name in spf_records:
            err_msg(
                f"Duplicate SPF record for {name} Resolve this manually, "
                "then re-run the utility"
            )
            sys.exit(1)
        spf_records[name] = (line, txtdata)
    return spf_records


def update_zone(domain: str, name: str, line: int, content: str) -> None:
    """Updates the DNS zone"""
    if not name.endswith('.'):
        name = name + '.'
    args = {
        'domain': domain,
        'name': name,
        'class': 'IN',
        'ttl': 900,
        'type': 'TXT',
        'txtdata': content,
    }
    try:
        if line is not None:  # We're updating an existing record
            whmapi1('editzonerecord', args | {'line': line}, check=True)
        else:  # We're creating a new zone record
            whmapi1('addzonerecord', args, check=True)
    except CpAPIError as exc:
        print(red(f'\tError updating zone: {exc}'))


def delete_spf(domain: str, line: str) -> None:
    """Deletes an SPF record"""
    try:
        whmapi1('removezonerecord', {'zone': domain, 'line': line}, check=True)
    except CpAPIError as exc:
        print(red(f'\tError updating zone: {exc}'))
    else:
        print(green('\tSPF TXT record removed'))


def generate_spf(
    *,
    mail_ips: list[str],
    existing_spf: str,
    domain: str,
    domain_ip: Union[str, None],
    overwrite: bool,
):
    """Generates the SPF record"""
    # The only difference between 'creating' and 'updating' a record is
    # 'updating' will ignore stuff that's already in the record,
    # unless it's wrong

    if overwrite or existing_spf is None:
        # If we're overwriting the record, we don't really care what's in it
        # This is the default record to include SE and server hostname
        # (all mail ips on this server)
        if domain_ip:
            new_record = 'v=spf1 +a +mx +a:{} +a:{} +ip4:{} ~all'.format(
                HOSTNAME,
                SPAMEXP_HOSTNAME,
                domain_ip,
            )
        else:
            new_record = 'v=spf1 +a +mx +a:{} +a:{} ~all'.format(
                HOSTNAME,
                SPAMEXP_HOSTNAME,
            )
        return new_record

    # If we have a record already, split it up so it's easier to parse
    parts = existing_spf.split()

    # Now we have to further split each part into sections
    # See: http://www.openspf.org/SPF_Record_Syntax

    mechanisms = ['+', '-', '~', '?']
    data = OrderedDict()
    index = 0
    for part in parts:

        if part.startswith('v='):
            data['version'] = {'mech': '', 'qual': '', 'val': part}
            index += 1
            continue

        # Check for modifiers
        if part.startswith('redirect='):
            _, val = part.split('=')
            if val != '':
                # If we have a modifier, we can basically just generate a
                # new record
                new_record = 'v=spf1 redirect=%s' % (val)
                return new_record
            # If we're missing a value though, remove this entirely
            continue

        if part.startswith('exp='):
            _, val = part.split('=')
            if val != '':
                data['exp'] = {'mech': '', 'qual': '', 'val': 'exp=%s' % val}
            continue

        # If the part starts with a mechanism...
        if any(part.startswith(m) for m in mechanisms):
            mech = part[0]
            part = part.lstrip(part[0])
        else:
            mech = ''

        # If the part contains a ":", add a value. Otherwise replace with a ''
        if ':' in part:
            qual, val = part.split(':')
        else:
            qual = part
            val = ''

        data[index] = {'mech': mech, 'qual': qual, 'val': val}
        index += 1

    # This is where we actually generate the record
    present = {
        'version': False,
        'server_host': False,
        'domain_mx': False,
        'domain_a': False,
        'domain_mailip': False,
        'se_host': False,
        'pass_fail': False,
    }
    delete = []

    for key, value in data.items():

        # ipv4 that matches mailips or domain ip
        if value['qual'] == 'ip4':
            if value['val'] in mail_ips:
                delete.append(key)
            if domain_ip is None:
                # we don't need this value if the domain uses a shared IP
                present['domain_mailip'] = True
            else:
                if value['val'] == domain_ip:
                    present['domain_mailip'] = True

        # Other servers' hostnames of ours
        if value['val'] not in (HOSTNAME, SPAMEXP_HOSTNAME):
            if (
                'inmotionhosting.com' in value['val']
                or 'webhostinghub.com' in value['val']
                or 'servconfig.com' in value['val']
            ):
                delete.append(key)

        # version
        if value['qual'] == '' and "v=" in value['val']:
            present['version'] = True

        # domain, server, & SE hostnames
        if value['qual'] == 'a':
            if value['val'] == HOSTNAME:
                present['server_host'] = True
            if value['val'] == SPAMEXP_HOSTNAME:
                present['se_host'] = True
            if value['val'] == domain or value['val'] == '':
                present['domain_a'] = True

        # The domain's MX
        if value['qual'] == 'mx':
            if value['val'] == domain or value['val'] == '':
                present['domain_mx'] = True

        # action
        if value['qual'] == "all":
            present['pass_fail'] = True
            data['pass'] = value
            if isinstance(key, int):  # delete the numerical key
                delete.append(key)

    for item in delete:
        data.pop(item)

    # Add anything that's missing
    if not present['domain_mailip'] and domain_ip is not None:
        data[index] = {'mech': '+', 'qual': 'a', 'val': domain_ip}
        index += 1
    if not present['version']:
        data['version'] = {'mech': '', 'qual': '', 'val': 'v=spf1'}
        index += 1
    if not present['server_host']:
        data[index] = {'mech': '+', 'qual': 'a', 'val': HOSTNAME}
        index += 1
    if not present['se_host']:
        data[index] = {'mech': '+', 'qual': 'a', 'val': SPAMEXP_HOSTNAME}
        index += 1
    if not present['domain_a']:
        data[index] = {'mech': '+', 'qual': 'a', 'val': ''}
        index += 1
    if not present['domain_mx']:
        data[index] = {'mech': '+', 'qual': 'mx', 'val': ''}
        index += 1
    if not present['pass_fail']:
        data['pass'] = {'mech': '~', 'qual': 'all', 'val': ''}
        index += 1

    # Now put all the parts together to make a new SPF record

    new_map = []
    new_map.append(data['version'])  # version first
    for key, val in sorted(data.items()):
        if isinstance(key, int):
            new_map.append(val)

    # The pass/fail and exp modifier should be last
    try:
        new_map.append(data['pass'])
    except KeyError:
        pass

    try:
        new_map.append(data['exp'])
    except KeyError:
        pass

    # Now put all the SPF elements together
    data = []  # reuse this one
    for item in new_map:
        if item['mech'] != '':
            if item['qual'] != '':
                line = '{}{}'.format(item['mech'], item['qual'])
            else:
                line = item['mech']
        else:
            line = '%s' % item['qual']

        if item['val'] != '' and item['qual'] != '':
            line = '{}:{}'.format(line, item['val'])
        else:
            line = '{}{}'.format(line, item['val'])

        data.append(line.strip())

    new_record = ' '.join(data)

    return new_record


if __name__ == "__main__":
    main()

Zerion Mini Shell 1.0