Mini Shell

Direktori : /opt/sharedrads/
Upload File :
Current File : //opt/sharedrads/dns-sync

#!/opt/imh-python/bin/python3
"""
Attempt to sync a DNS zone to our cluster, and print any known errors.

Provides a bunch of additional checking for common causes of issues syncing.

Wraps dnscluster synczone and reads /usr/local/cpanel/logs/error_log for a result.

-- Common errors --

Error 401: Unauthorized
    This means there is something wrong with the DNS cluster key located in
    /var/cpanel/cluster/. This could mean that the DNS key isn't active, or that
    it needs to be installed from PowerPanel. For VPS+ accounts, this can mean
    that the domain's cPanel account has a reseller that is not set up with the
    user's current PowerPanel API key.
    In rare cases, the key may need to be activated directly on the
    DNS authority server by Systems.

Error 409: Conflict
    DNS authority for this domain is owned by another server. If the other
    server is verified, reset DNS authority for the domain.
    Always follow policy for this.

No result when searching for log entry in /usr/local/cpanel/logs/error_log
    This typically indicates that dnsadmin didn't attempt to sync the zone to our servers.

DNS cluster key not set up
    On VPS+ this indicates there is no DNS cluster configuration file for the
    reseller account that owns the cPanel account the domain belongs to.
    If activating the DNS key through PowerPanel does not resolve this, check
    if the reseller account is the primary account in PowerPanel. If it is not,
    copy the DNS cluster configuration from /var/cpanel/cluster/ for the primary
    reseller into a new directory with the name of the the correct reseller.
    e.g. rsync -avP /var/cpanel/cluster/mainreseller5/ /var/cpanel/cluster/reseller2

    On shared, this simply means the DNS key needs to be activated from PowerPanel.

Reseller does not exist on the server
    This means the domain's cPanel account has a reseller owner that isn't on
    the server. Typically the result of a server transfer, simply assign a
    valid reseller owner to the domain's cPanel account.

"""
import re
import subprocess
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
from datetime import datetime
from os import isatty
from os.path import exists
from rads import whmapi1, whoowns, cpuser_safe


class SyncError(Exception):
    """
    An exception raised when an issue is detected when attempting to sync DNS
    to the cluster, either preventing it from succeeding or a detected failure.
    """


class SerialUpdateError(Exception):
    """
    An exception raised when an issue is detected when attempting to update
    the serial number in an SOA record, either preventing it from succeeding or
    a detected failure
    """


def get_args():
    parser = ArgumentParser(
        description=__doc__, formatter_class=RawTextHelpFormatter
    )
    # fmt: off
    parser.add_argument(
        "domains", metavar="domain", nargs='+', default=[],
        help="Domain(s) to sync",
    )
    parser.add_argument(
        "-s", "--serial-update", default=False, action="store_true",
        help="Force SOA serial update",
    )
    # fmt: on
    return parser.parse_args()


def process_entry(domain, error, error_code, message):
    """
    Process status message from a dnsadmin log line
    """
    reply = re.search(r" server replied: (.+)", message)
    user_msg = ""
    if error:
        user_msg += f"{error}. "
    success = False

    if reply:
        if "Success" in reply.group(1):
            user_msg += "Successfully synced to DNS cluster"
            success = True
        else:
            user_msg += reply.group(1)
    else:
        user_msg += message

    if error_code:
        status_messages = {
            "409": (
                "Zone ownership may need to be reset in the DNS authority "
                "server."
            ),
            "401": (
                "This may mean that the DNS key needs to be activated, "
                "reset, or installed. Check /var/cpanel/cluster/"
            ),
            "500": (
                "Inspect cPanel error log and logs on DNS" " authority server"
            ),
        }
        if error_code in status_messages:
            user_msg += f". {status_messages[error_code]}"
    if success:
        log_success(domain, user_msg)
    else:
        log_error(domain, user_msg)


def sync_zone(domain):
    """
    Attempt to sync a zone to the cluster
    """
    owner = whoowns(domain)
    if not owner:
        raise SyncError(
            "Failed to locate cPanel owner of domain. Is it on this server?"
        )
    if not cpuser_safe(owner):
        raise SyncError(
            f"Unable to synchronize domain owned by secure user {owner}"
        )
    if not exists(f"/var/cpanel/cluster/{owner}/config/imh"):
        dns_owner = reseller_owner(owner)
        if not exists(f"/var/cpanel/cluster/{dns_owner}/config/imh"):
            if dns_owner in ["inmotion", "hubhost"]:  # Shared does per-user
                raise SyncError(
                    f"DNS cluster key is not set up for {owner} or "
                    f"reseller {dns_owner}. On shared, this only needs to be "
                    "configured for the user"
                )
            raise SyncError(
                "DNS cluster key not set up for "
                f"reseller {dns_owner}. Unable to sync."
            )
    try:
        synczone_args = [
            "/usr/local/cpanel/scripts/dnscluster",
            "synczone",
            domain,
        ]
        with subprocess.Popen(
            synczone_args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
        ) as proc:
            output = proc.communicate()
    except FileNotFoundError:
        sys.exit(
            "Unable to locate /usr/local/cpanel/scripts/dnscluster on server. "
            "Is this a cPanel server?"
        )

    sync_output = output[0].decode("utf-8", "ignore")
    if "...Done" not in sync_output:
        # Print as an error, trim "Done" line
        trimmed = sync_output.replace("\nDone\n", "")
        raise SyncError(trimmed)

    return True


def sync_zones(domains):
    """
    Sync domains to cluster, reading the cPanel error log for results.
    """
    with open("/usr/local/cpanel/logs/error_log") as log:
        for domain in [domain.lower() for domain in domains]:
            log.seek(0, 2)  # Seek to the end, because we are tailing the file
            try:
                sync_zone(domain)
            except SyncError as e:
                log_error(domain, str(e))
                continue
            entry_found = False
            # Search for log entry like:
            # [2020-10-19 17:50:59 +0000] info [dnsadmin] update_zone (domain)
            for entry in reversed(log.readlines()):
                entry = entry.strip()
                year = r"\d{4}"
                month = day = hour = minute = second = r"\d{2}"
                # We're doing it this way to make this regex slightly more
                # readable.
                date = f"{year}-{month}-{day} {hour}:{minute}:{second}"

                # We are very strict about the format as quite a lot is dumped
                # into this log file. I apologize to future me (or whomever)
                # that have to maintain this.
                match = re.search(
                    (
                        r"^\[{} [+-]\d+\] \w* \[dnsadmin\] "
                        r"update_zone \({}\) (Error (\d+): \w+)?(.*)$"
                    ).format(date, domain),
                    entry,
                )

                if match:
                    entry_found = True

                    try:
                        process_entry(
                            domain,
                            match.group(1),
                            match.group(2),
                            match.group(3),
                        )
                    except SyncError as e:
                        log_error(domain, str(e))

            if not entry_found:
                log_error(
                    domain,
                    (
                        "No result when searching for log entry in "
                        "/usr/local/cpanel/logs/error_log"
                    ),
                )


def fetch_zone(domain):
    """
    Fetch zone file from cPanel for a given domain.
    Raises SerialUpdateError upon failure
    """
    response = whmapi1("dumpzone", {"domain": domain})
    if not response:
        raise SerialUpdateError(
            "An unknown error occured running whmapi1 dumpzone. "
            "This could be due to a missing access hash or network "
            "error"
        )

    if "metadata" in response:
        status_code = response["metadata"].get("result")
        reason = response["metadata"].get("reason")
        if status_code != 1:
            raise SerialUpdateError(
                "Unable to update serial. whmapi1 dumpzone " f"failed. {reason}"
            )

    if "data" not in response:
        #  If we hit this block, I have no idea what's wrong. We're gonna
        #  panic and print the full error message.
        print(response, file=sys.stderr)
        raise SerialUpdateError(
            "Unable to update serial. whmapi1 dumpzone returned no data."
        )

    data = response["data"]
    if (
        "zone" not in data
        or len(data["zone"]) == 0
        or "record" not in data["zone"][0]
    ):
        raise SerialUpdateError(
            "Unable to update serial. whmapi1 dumpzone returned no zone."
        )

    return data["zone"][0]["record"]


def update_serial(domain):
    """
    Update zone file serial in SOA record for a given domain
    Raised SerialUpdateError on failure
    """
    owner = whoowns(domain)
    if owner and not cpuser_safe(owner):
        raise SerialUpdateError(
            f"Unable to update SOA for domain owned by secure user {owner}"
        )

    zone_file_path = f"/var/named/{domain}.db"
    if not exists(zone_file_path):
        raise SerialUpdateError(
            "Unable to update serial, zone file doesn't exist."
        )

    zone = fetch_zone(domain)
    if not zone:
        raise SerialUpdateError("Unable to update serial. Zone file is empty")
    soa_record = None
    for record in zone:
        if record.get("name") == f"{domain}." and record.get("type") == "SOA":
            soa_record = record
            break
    if not soa_record:
        raise SerialUpdateError("SOA record not found in zone file")

    serial = str(soa_record["serial"])
    date_fmt = "%Y%m%d"  # e.g Oct 1 is 20201001
    new_date = int(datetime.today().strftime(date_fmt))
    counter = 0
    if len(serial) != 10:  # e.g. 2020101000
        serial_date = new_date  # Not a standard format, overwrite.
    else:
        try:
            serial_date = int(serial[:-2])
            counter = int(serial[-2:]) + 1
        except ValueError:
            serial_date = new_date

        if serial_date < new_date:  # Preserve 'future' date
            serial_date = new_date
            counter = 0

    zone_file_path = f"/var/named/{domain}.db"
    with open(zone_file_path) as zone_file:
        lines = zone_file.readlines()

    new_serial = f"{serial_date}{str(counter).zfill(2)}"
    line = soa_record["Line"]
    record_len = soa_record.get("Lines", 1)  # There's at least one line
    soa_lines = lines[line : line + record_len]

    for i in range(record_len):
        lines[line + i] = re.sub(
            fr"^([^;]+){serial}", fr"\g<1>{new_serial}", soa_lines[i]
        )

    with open(zone_file_path, "w") as zone_file:
        zone_file.write("".join(lines))  # Finally, write changes


def update_serials(domains):
    """
    Update zone file serial in SOA record for given domains
    """
    for domain in [domain.lower() for domain in domains]:
        try:
            update_serial(domain)
        except SerialUpdateError as err:
            log_error(domain, str(err))


def reseller_owner(owner):
    """
    Get a user's reseller owner, including root.
    """
    try:
        with open("/etc/trueuserowners") as file:
            match = next(x for x in file if x.startswith(f"{owner}: "))
            # userna5: root
            return match.rstrip().split(": ")[1]
    except FileNotFoundError as e:
        raise SyncError(
            f"Unable to find {e.filename}. " "Is this a cPanel server?"
        ) from e
    except OSError as e:
        raise SyncError(
            f"Unable to load {e.filename}. "
            "Do you have sufficient permissions?"
        ) from e
    except StopIteration as exc:
        raise SyncError(
            f"Reseller {owner} does not exist on the server"
        ) from exc


def log_error(domain, message):
    print(f"{style(domain, 'red')}: {message}")


def log_success(domain, message):
    print(f"{style(domain, 'green')}: {message}")


def style(text, color=None):
    """
    Stylize text with ANSI escape codes for red/green, if we're in a terminal
    """
    if not isatty(sys.stdin.fileno()) or color is None:
        return text  # Only output color to a terminal

    colors = {"red": 31, "green": 32}
    if color and color not in colors:
        raise ValueError(f"Color {color} isn't accepted")

    return "".join([f"\033[{colors[color]}m{text}\033[0m"])


def main():
    parsed = get_args()

    if exists("/etc/ansible/wordpress-ultrastack"):
        sys.exit(style(
            "DNS is not able to be managed by this container. Please manage "
            "your DNS on Platform i.\n"
            "https://www.inmotionhosting.com/support/product-guides/wordpress-hosting/central/domains/dns-management/"
        , 'red'))

    if not exists("/var/cpanel/useclusteringdns"):
        sys.exit(
            "DNS clustering isn't enabled on this server, please enable it!"
        )
    for domain in parsed.domains:
        if ".." in domain:
            # I can't think of a valid path traversal exploit using this tool,
            # however, I can't think of a reason to risk it either
            sys.exit(f"{domain} is not a valid domain name")

    if parsed.serial_update:
        update_serials(parsed.domains)

    sync_zones(parsed.domains)


if __name__ == "__main__":
    main()

Zerion Mini Shell 1.0