#!/usr/pkg/bin/python3.13
#
# Unix SMB/CIFS implementation.
# Copyright (C) Amitay Isaacs <amitay@gmail.com> 2012
#
# Upgrade DNS provision from BIND9_FLATFILE to BIND9_DLZ or SAMBA_INTERNAL
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import sys
import os
import errno
import optparse
import logging
import grp
from base64 import b64encode
import shlex

sys.path.insert(0, "bin/python")

import ldb
import samba
from samba import param
from samba.auth import system_session
from samba.ndr import (
    ndr_pack,
    ndr_unpack )
import samba.getopt as options
from samba.upgradehelpers import (
    get_paths,
    get_ldbs )
from samba.dsdb import DS_DOMAIN_FUNCTION_2003
from samba.provision import (
    find_provision_key_parameters,
    interface_ips_v4,
    interface_ips_v6 )
from samba.provision.common import (
    setup_path,
    setup_add_ldif,
    FILL_FULL)
from samba.provision.sambadns import (
    ARecord,
    AAAARecord,
    CNAMERecord,
    NSRecord,
    SOARecord,
    SRVRecord,
    TXTRecord,
    get_dnsadmins_sid,
    add_dns_accounts,
    create_dns_partitions,
    fill_dns_data_partitions,
    create_dns_dir,
    secretsdb_setup_dns,
    create_dns_dir_keytab_link,
    create_samdb_copy,
    create_named_conf,
    create_named_txt )
from samba.dcerpc import security

import dns.zone, dns.rdatatype

__docformat__ = 'restructuredText'


def find_bind_gid():
    """Find system group id for bind9
    """
    for name in ["bind", "named"]:
        try:
            return grp.getgrnam(name)[2]
        except KeyError:
            pass
    return None


def convert_dns_rdata(rdata, serial=1):
    """Convert resource records in dnsRecord format
    """
    if rdata.rdtype == dns.rdatatype.A:
        rec = ARecord(rdata.address, serial=serial)
    elif rdata.rdtype == dns.rdatatype.AAAA:
        rec = AAAARecord(rdata.address, serial=serial)
    elif rdata.rdtype == dns.rdatatype.CNAME:
        rec = CNAMERecord(rdata.target.to_text(), serial=serial)
    elif rdata.rdtype == dns.rdatatype.NS:
        rec = NSRecord(rdata.target.to_text(), serial=serial)
    elif rdata.rdtype == dns.rdatatype.SRV:
        rec = SRVRecord(rdata.target.to_text(), int(rdata.port),
                        priority=int(rdata.priority), weight=int(rdata.weight),
                        serial=serial)
    elif rdata.rdtype == dns.rdatatype.TXT:
        slist = shlex.split(rdata.to_text())
        rec = TXTRecord(slist, serial=serial)
    elif rdata.rdtype == dns.rdatatype.SOA:
        rec = SOARecord(rdata.mname.to_text(), rdata.rname.to_text(),
                        serial=int(rdata.serial),
                        refresh=int(rdata.refresh), retry=int(rdata.retry),
                        expire=int(rdata.expire), minimum=int(rdata.minimum))
    else:
        rec = None
    return rec


def import_zone_data(samdb, logger, zone, serial, domaindn, forestdn,
                     dnsdomain, dnsforest):
    """Insert zone data in DNS partitions
    """
    labels = dnsdomain.split('.')
    labels.append('')
    domain_root = dns.name.Name(labels)
    domain_prefix = "DC=%s,CN=MicrosoftDNS,DC=DomainDnsZones,%s" % (dnsdomain,
                                                                    domaindn)

    tmp = "_msdcs.%s" % dnsforest
    labels = tmp.split('.')
    labels.append('')
    forest_root = dns.name.Name(labels)
    dnsmsdcs = "_msdcs.%s" % dnsforest
    forest_prefix = "DC=%s,CN=MicrosoftDNS,DC=ForestDnsZones,%s" % (dnsmsdcs,
                                                                    forestdn)

    # Extract @ record
    at_record = zone.get_node(domain_root)
    zone.delete_node(domain_root)

    # SOA record
    rdset = at_record.get_rdataset(dns.rdataclass.IN, dns.rdatatype.SOA)
    soa_rec = ndr_pack(convert_dns_rdata(rdset[0]))
    at_record.delete_rdataset(dns.rdataclass.IN, dns.rdatatype.SOA)

    # NS record
    rdset = at_record.get_rdataset(dns.rdataclass.IN, dns.rdatatype.NS)
    ns_rec = ndr_pack(convert_dns_rdata(rdset[0]))
    at_record.delete_rdataset(dns.rdataclass.IN, dns.rdatatype.NS)

    # A/AAAA records
    ip_recs = []
    for rdset in at_record:
        for r in rdset:
            rec = convert_dns_rdata(r)
            ip_recs.append(ndr_pack(rec))

    # Add @ record for domain
    dns_rec = [soa_rec, ns_rec] + ip_recs
    msg = ldb.Message(ldb.Dn(samdb, 'DC=@,%s' % domain_prefix))
    msg["objectClass"] = ["top", "dnsNode"]
    msg["dnsRecord"] = ldb.MessageElement(dns_rec, ldb.FLAG_MOD_ADD,
                                          "dnsRecord")
    try:
        samdb.add(msg)
    except Exception:
        logger.error("Failed to add @ record for domain")
        raise
    logger.debug("Added @ record for domain")

    # Add @ record for forest
    dns_rec = [soa_rec, ns_rec]
    msg = ldb.Message(ldb.Dn(samdb, 'DC=@,%s' % forest_prefix))
    msg["objectClass"] = ["top", "dnsNode"]
    msg["dnsRecord"] = ldb.MessageElement(dns_rec, ldb.FLAG_MOD_ADD,
                                          "dnsRecord")
    try:
        samdb.add(msg)
    except Exception:
        logger.error("Failed to add @ record for forest")
        raise
    logger.debug("Added @ record for forest")

    # Add remaining records in domain and forest
    for node in zone.nodes:
        name = node.relativize(forest_root).to_text()
        if name == node.to_text():
            name = node.relativize(domain_root).to_text()
            dn = "DC=%s,%s" % (name, domain_prefix)
            fqdn = "%s.%s" % (name, dnsdomain)
        else:
            dn = "DC=%s,%s" % (name, forest_prefix)
            fqdn = "%s.%s" % (name, dnsmsdcs)

        dns_rec = []
        for rdataset in zone.nodes[node]:
            for rdata in rdataset:
                rec = convert_dns_rdata(rdata, serial)
                if not rec:
                    logger.warn("Unsupported record type (%s) for %s, ignoring" %
                                dns.rdatatype.to_text(rdata.rdatatype), name)
                else:
                    dns_rec.append(ndr_pack(rec))

        msg = ldb.Message(ldb.Dn(samdb, dn))
        msg["objectClass"] = ["top", "dnsNode"]
        msg["dnsRecord"] = ldb.MessageElement(dns_rec, ldb.FLAG_MOD_ADD,
                                              "dnsRecord")
        try:
            samdb.add(msg)
        except Exception:
            logger.error("Failed to add DNS record %s" % (fqdn))
            raise
        logger.debug("Added DNS record %s" % (fqdn))

def cleanup_remove_file(file_path):
    try:
        os.remove(file_path)
    except OSError as e:
        if e.errno not in [errno.EEXIST, errno.ENOENT]:
            pass
        else:
            logger.debug("Could not remove %s: %s" % (file_path, e.strerror))

def cleanup_remove_dir(dir_path):
    try:
        for root, dirs, files in os.walk(dir_path, topdown=False):
            for name in files:
                os.remove(os.path.join(root, name))
            for name in dirs:
                os.rmdir(os.path.join(root, name))
        os.rmdir(dir_path)
    except OSError as e:
        if e.errno not in [errno.EEXIST, errno.ENOENT]:
            pass
        else:
            logger.debug("Could not delete dir %s: %s" % (dir_path, e.strerror))

def cleanup_obsolete_dns_files(paths):
    cleanup_remove_file(os.path.join(paths.private_dir, "named.conf"))
    cleanup_remove_file(os.path.join(paths.private_dir, "named.conf.update"))
    cleanup_remove_file(os.path.join(paths.private_dir, "named.txt"))

    cleanup_remove_dir(os.path.join(paths.private_dir, "dns"))


# dnsprovision creates application partitions for AD based DNS mainly if the existing
# provision was created using earlier snapshots of samba4 which did not have support
# for DNS partitions

if __name__ == '__main__':

    # Setup command line parser
    parser = optparse.OptionParser("samba_upgradedns [options]")
    sambaopts = options.SambaOptions(parser)
    credopts = options.CredentialsOptions(parser)

    parser.add_option_group(options.VersionOptions(parser))
    parser.add_option_group(sambaopts)
    parser.add_option_group(credopts)

    parser.add_option("--dns-backend", type="choice", metavar="<BIND9_DLZ|SAMBA_INTERNAL>",
                      choices=["SAMBA_INTERNAL", "BIND9_DLZ"], default="SAMBA_INTERNAL",
                      help="The DNS server backend, default SAMBA_INTERNAL")
    parser.add_option("--migrate", type="choice", metavar="<yes|no>",
                      choices=["yes","no"], default="yes",
                      help="Migrate existing zone data, default yes")
    parser.add_option("--verbose", help="Be verbose", action="store_true")

    opts = parser.parse_args()[0]

    if opts.dns_backend is None:
        opts.dns_backend = 'SAMBA_INTERNAL'

    if opts.migrate:
        autofill = False
    else:
        autofill = True

    # Set up logger
    logger = logging.getLogger("upgradedns")
    logger.addHandler(logging.StreamHandler(sys.stdout))
    logger.setLevel(logging.INFO)
    if opts.verbose:
        logger.setLevel(logging.DEBUG)

    lp = sambaopts.get_loadparm()
    lp.load(lp.configfile)
    creds = credopts.get_credentials(lp)

    logger.info("Reading domain information")
    paths = get_paths(param, smbconf=lp.configfile)
    paths.bind_gid = find_bind_gid()
    ldbs = get_ldbs(paths, creds, system_session(), lp)
    names = find_provision_key_parameters(ldbs.sam, ldbs.secrets, ldbs.idmap,
                                           paths, lp.configfile, lp)

    if names.domainlevel < DS_DOMAIN_FUNCTION_2003:
        logger.error("Cannot create AD based DNS for OS level < 2003")
        sys.exit(1)

    domaindn = names.domaindn
    forestdn = names.rootdn

    dnsdomain = names.dnsdomain.lower()
    dnsforest = dnsdomain

    site = names.sitename
    hostname = names.hostname
    dnsname = '%s.%s' % (hostname, dnsdomain)

    domainsid = names.domainsid
    domainguid = names.domainguid
    ntdsguid = names.ntdsguid

    # Check for DNS accounts and create them if required
    try:
        msg = ldbs.sam.search(base=domaindn, scope=ldb.SCOPE_DEFAULT,
                              expression='(sAMAccountName=DnsAdmins)',
                              attrs=['objectSid'])
        dnsadmins_sid = ndr_unpack(security.dom_sid, msg[0]['objectSid'][0])
    except IndexError:
        logger.info("Adding DNS accounts")
        add_dns_accounts(ldbs.sam, domaindn)
        dnsadmins_sid = get_dnsadmins_sid(ldbs.sam, domaindn)
    else:
        logger.info("DNS accounts already exist")

    # Import dns records from zone file
    if os.path.exists(paths.dns):
        logger.info("Reading records from zone file %s" % paths.dns)
        try:
            zone = dns.zone.from_file(paths.dns, relativize=False)
            rrset = zone.get_rdataset("%s." % dnsdomain, dns.rdatatype.SOA)
            serial = int(rrset[0].serial)
        except Exception as e:
            logger.warn("Error parsing DNS data from '%s' (%s)" % (paths.dns, str(e)))
            autofill = True
    else:
        logger.info("No zone file %s (normal)" % paths.dns)
        autofill = True

    # Create DNS partitions if missing and fill DNS information
    try:
        expression = '(|(dnsRoot=DomainDnsZones.%s)(dnsRoot=ForestDnsZones.%s))' % \
                     (dnsdomain, dnsforest)
        msg = ldbs.sam.search(base=names.configdn, scope=ldb.SCOPE_DEFAULT,
                              expression=expression, attrs=['nCName'])
        ncname = msg[0]['nCName'][0]
    except IndexError:
        logger.info("Creating DNS partitions")

        logger.info("Looking up IPv4 addresses")
        hostip = interface_ips_v4(lp)
        try:
            hostip.remove('127.0.0.1')
        except ValueError:
            pass
        if not hostip:
            logger.error("No IPv4 addresses found")
            sys.exit(1)
        else:
            hostip = hostip[0]
            logger.debug("IPv4 addresses: %s" % hostip)

        logger.info("Looking up IPv6 addresses")
        hostip6 = interface_ips_v6(lp)
        if not hostip6:
            hostip6 = None
        else:
            hostip6 = hostip6[0]
        logger.debug("IPv6 addresses: %s" % hostip6)

        create_dns_partitions(ldbs.sam, domainsid, names, domaindn, forestdn,
                              dnsadmins_sid, FILL_FULL)

        logger.info("Populating DNS partitions")
        if autofill:
            logger.warn("DNS records will be automatically created")

        fill_dns_data_partitions(ldbs.sam, domainsid, site, domaindn, forestdn,
                             dnsdomain, dnsforest, hostname, hostip, hostip6,
                             domainguid, ntdsguid, dnsadmins_sid,
                             autofill=autofill)

        if not autofill:
            logger.info("Importing records from zone file")
            import_zone_data(ldbs.sam, logger, zone, serial, domaindn, forestdn,
                             dnsdomain, dnsforest)
    else:
        logger.info("DNS partitions already exist")

    # Mark that we are hosting DNS partitions
    try:
        dns_nclist = [ 'DC=DomainDnsZones,%s' % domaindn,
                       'DC=ForestDnsZones,%s' % forestdn ]

        msgs = ldbs.sam.search(base=names.serverdn, scope=ldb.SCOPE_DEFAULT,
                               expression='(objectclass=nTDSDSa)',
                               attrs=['hasPartialReplicaNCs',
                                      'msDS-hasMasterNCs'])
        msg = msgs[0]

        master_nclist = []
        ncs = msg.get("msDS-hasMasterNCs")
        if ncs:
            for nc in ncs:
                master_nclist.append(str(nc))

        partial_nclist = []
        ncs = msg.get("hasPartialReplicaNCs")
        if ncs:
            for nc in ncs:
                partial_nclist.append(str(nc))

        modified_master = False
        modified_partial = False

        for nc in dns_nclist:
            if nc not in master_nclist:
                master_nclist.append(nc)
                modified_master = True
            if nc in partial_nclist:
                partial_nclist.remove(nc)
                modified_partial = True

        if modified_master or modified_partial:
            logger.debug("Updating msDS-hasMasterNCs and hasPartialReplicaNCs attributes")
            m = ldb.Message()
            m.dn = msg.dn
            if modified_master:
                m["msDS-hasMasterNCs"] = ldb.MessageElement(master_nclist,
                                                            ldb.FLAG_MOD_REPLACE,
                                                            "msDS-hasMasterNCs")
            if modified_partial:
                if partial_nclist:
                    m["hasPartialReplicaNCs"] = ldb.MessageElement(partial_nclist,
                                                                   ldb.FLAG_MOD_REPLACE,
                                                                   "hasPartialReplicaNCs")
                else:
                    m["hasPartialReplicaNCs"] = ldb.MessageElement(ncs,
                                                                   ldb.FLAG_MOD_DELETE,
                                                                   "hasPartialReplicaNCs")
            ldbs.sam.modify(m)
    except Exception:
        raise

    # Special stuff for DLZ backend
    if opts.dns_backend == "BIND9_DLZ":
        config_migration = False

        if (paths.private_dir != paths.binddns_dir and
            os.path.isfile(os.path.join(paths.private_dir, "named.conf"))):
            config_migration = True

        # Check if dns-HOSTNAME account exists and create it if required
        secrets_msgs = ldbs.secrets.search(expression='(samAccountName=dns-%s)' % hostname, attrs=['secret'])
        msg = ldbs.sam.search(base=domaindn, scope=ldb.SCOPE_DEFAULT,
                              expression='(sAMAccountName=dns-%s)' % (hostname),
                              attrs=[])

        if len(secrets_msgs) == 0 or len(msg) == 0:
            logger.info("Adding dns-%s account" % hostname)

            if len(secrets_msgs) == 1:
                dn = secrets_msgs[0].dn
                ldbs.secrets.delete(dn)

            if len(msg) == 1:
                dn = msg[0].dn
                ldbs.sam.delete(dn)

            dnspass = samba.generate_random_password(128, 255)
            setup_add_ldif(ldbs.sam, setup_path("provision_dns_add_samba.ldif"), {
                    "DNSDOMAIN": dnsdomain,
                    "DOMAINDN": domaindn,
                    "DNSPASS_B64": b64encode(dnspass.encode('utf-16-le')).decode('utf8'),
                    "HOSTNAME" : hostname,
                    "DNSNAME" : dnsname }
                           )

            res = ldbs.sam.search(base=domaindn, scope=ldb.SCOPE_DEFAULT,
                                  expression='(sAMAccountName=dns-%s)' % (hostname),
                                  attrs=["msDS-KeyVersionNumber"])
            if "msDS-KeyVersionNumber" in res[0]:
                dns_key_version_number = int(res[0]["msDS-KeyVersionNumber"][0])
            else:
                dns_key_version_number = None

            secretsdb_setup_dns(ldbs.secrets, names,
                                paths.private_dir, paths.binddns_dir, realm=names.realm,
                                dnsdomain=names.dnsdomain,
                                dns_keytab_path=paths.dns_keytab, dnspass=dnspass,
                                key_version_number=dns_key_version_number)

        else:
            logger.info("dns-%s account already exists" % hostname)

        if not os.path.exists(paths.binddns_dir):
            # This directory won't exist if we're restoring from an offline backup.
            os.mkdir(paths.binddns_dir, 0o770)

        create_dns_dir_keytab_link(logger, paths)

        # This forces a re-creation of dns directory and all the files within
        # It's an overkill, but it's easier to re-create a samdb copy, rather
        # than trying to fix a broken copy.
        create_dns_dir(logger, paths)

        # Setup a copy of SAM for BIND9
        create_samdb_copy(ldbs.sam, logger, paths, names, domainsid,
                          domainguid)

        create_named_conf(paths, names.realm, dnsdomain, opts.dns_backend, logger)

        create_named_txt(paths.namedtxt, names.realm, dnsdomain, dnsname,
                         paths.binddns_dir, paths.dns_keytab)

        cleanup_obsolete_dns_files(paths)

        if config_migration:
            logger.info("ATTENTION: The BIND configuration and keytab has been moved to: %s",
                        paths.binddns_dir)
            logger.info("           Please update your BIND configuration accordingly.")
        else:
            logger.info("See %s for an example configuration include file for BIND", paths.namedconf)
            logger.info("and %s for further documentation required for secure DNS "
                        "updates", paths.namedtxt)

    elif opts.dns_backend == "SAMBA_INTERNAL":
        # Make sure to remove everything from the bind-dns directory to avoid
        # possible security issues with the named group having write access
        # to all AD partitions
        cleanup_remove_file(os.path.join(paths.binddns_dir, "dns.keytab"))
        cleanup_remove_file(os.path.join(paths.binddns_dir, "named.conf"))
        cleanup_remove_file(os.path.join(paths.binddns_dir, "named.conf.update"))
        cleanup_remove_file(os.path.join(paths.binddns_dir, "named.txt"))

        cleanup_remove_dir(os.path.dirname(paths.dns))

        try:
            os.chmod(paths.private_dir, 0o700)
            os.chown(paths.private_dir, -1, 0)
        except:
            logger.warn("Failed to restore owner and permissions for %s",
                        (paths.private_dir))

        # Check if dns-HOSTNAME account exists and delete it if required
        try:
            dn_str = 'samAccountName=dns-%s,CN=Principals' % hostname
            msg = ldbs.secrets.search(expression='(dn=%s)' % dn_str, attrs=[])
            dn = msg[0].dn
        except IndexError:
            dn = None

        if dn is not None:
            try:
                ldbs.secrets.delete(dn)
            except Exception:
                logger.info("Failed to delete %s from secrets.ldb" % dn)

        try:
            msg = ldbs.sam.search(base=domaindn, scope=ldb.SCOPE_DEFAULT,
                                  expression='(sAMAccountName=dns-%s)' % (hostname),
                                  attrs=[])
            dn = msg[0].dn
        except IndexError:
            dn = None

        if dn is not None:
            try:
                ldbs.sam.delete(dn)
            except Exception:
                logger.info("Failed to delete %s from sam.ldb" % dn)

    logger.info("Finished upgrading DNS")

    services = lp.get("server services")
    for service in services:
        if service == "dns":
            if opts.dns_backend.startswith("BIND"):
                logger.info("You have switched to using %s as your dns backend,"
                        " but still have the internal dns starting. Please"
                        " make sure you add '-dns' to your server services"
                        " line in your smb.conf." % opts.dns_backend)
            break
    else:
        if opts.dns_backend == "SAMBA_INTERNAL":
            logger.info("You have switched to using %s as your dns backend,"
                    " but you still have samba starting looking for a"
                    " BIND backend. Please remove the -dns from your"
                    " server services line." % opts.dns_backend)
