diff options
author | Jan Cholasta <jcholast@redhat.com> | 2016-04-28 10:30:05 +0200 |
---|---|---|
committer | Jan Cholasta <jcholast@redhat.com> | 2016-06-03 09:00:34 +0200 |
commit | 6e44557b601f769d23ee74555a72e8b5cc62c0c9 (patch) | |
tree | eedd3e054b0709341b9f58c190ea54f999f7d13a /ipaserver/plugins/dns.py | |
parent | ec841e5d7ab29d08de294b3fa863a631cd50e30a (diff) | |
download | freeipa-6e44557b601f769d23ee74555a72e8b5cc62c0c9.tar.gz freeipa-6e44557b601f769d23ee74555a72e8b5cc62c0c9.tar.xz freeipa-6e44557b601f769d23ee74555a72e8b5cc62c0c9.zip |
ipalib: move server-side plugins to ipaserver
Move the remaining plugin code from ipalib.plugins to ipaserver.plugins.
Remove the now unused ipalib.plugins package.
https://fedorahosted.org/freeipa/ticket/4739
Reviewed-By: David Kupka <dkupka@redhat.com>
Diffstat (limited to 'ipaserver/plugins/dns.py')
-rw-r--r-- | ipaserver/plugins/dns.py | 4396 |
1 files changed, 4396 insertions, 0 deletions
diff --git a/ipaserver/plugins/dns.py b/ipaserver/plugins/dns.py new file mode 100644 index 000000000..9cca07c6d --- /dev/null +++ b/ipaserver/plugins/dns.py @@ -0,0 +1,4396 @@ +# Authors: +# Martin Kosek <mkosek@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# 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/>. + +from __future__ import absolute_import + +import netaddr +import time +import re +import binascii +import encodings.idna + +import dns.name +import dns.exception +import dns.rdatatype +import dns.resolver +import six + +from ipalib.dns import (get_record_rrtype, + get_rrparam_from_part, + has_cli_options, + iterate_rrparams_by_parts, + record_name_format) +from ipalib.request import context +from ipalib import api, errors, output +from ipalib import Command +from ipalib.capabilities import ( + VERSION_WITHOUT_CAPABILITIES, + client_has_capability) +from ipalib.parameters import (Flag, Bool, Int, Decimal, Str, StrEnum, Any, + DNSNameParam) +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + LDAPObject, + LDAPCreate, + LDAPUpdate, + LDAPSearch, + LDAPQuery, + LDAPDelete, + LDAPRetrieve) +from ipalib import _ +from ipalib import messages +from ipalib.util import (normalize_zonemgr, + get_dns_forward_zone_update_policy, + get_dns_reverse_zone_update_policy, + get_reverse_zone_default, REVERSE_DNS_ZONES, + normalize_zone, validate_dnssec_global_forwarder, + DNSSECSignatureMissingError, UnresolvableRecordError, + EDNS0UnsupportedError, DNSSECValidationError, + validate_dnssec_zone_forwarder_step1, + validate_dnssec_zone_forwarder_step2, + verify_host_resolvable) +from ipapython.dn import DN +from ipapython.ipautil import CheckedIPAddress +from ipapython.dnsutil import check_zone_overlap +from ipapython.dnsutil import DNSName +from ipapython.dnsutil import related_to_auto_empty_zone + +if six.PY3: + unicode = str + +__doc__ = _(""" +Domain Name System (DNS) +""") + _(""" +Manage DNS zone and resource records. +""") + _(""" +SUPPORTED ZONE TYPES + + * Master zone (dnszone-*), contains authoritative data. + * Forward zone (dnsforwardzone-*), forwards queries to configured forwarders + (a set of DNS servers). +""") + _(""" +USING STRUCTURED PER-TYPE OPTIONS +""") + _(""" +There are many structured DNS RR types where DNS data stored in LDAP server +is not just a scalar value, for example an IP address or a domain name, but +a data structure which may be often complex. A good example is a LOC record +[RFC1876] which consists of many mandatory and optional parts (degrees, +minutes, seconds of latitude and longitude, altitude or precision). +""") + _(""" +It may be difficult to manipulate such DNS records without making a mistake +and entering an invalid value. DNS module provides an abstraction over these +raw records and allows to manipulate each RR type with specific options. For +each supported RR type, DNS module provides a standard option to manipulate +a raw records with format --<rrtype>-rec, e.g. --mx-rec, and special options +for every part of the RR structure with format --<rrtype>-<partname>, e.g. +--mx-preference and --mx-exchanger. +""") + _(""" +When adding a record, either RR specific options or standard option for a raw +value can be used, they just should not be combined in one add operation. When +modifying an existing entry, new RR specific options can be used to change +one part of a DNS record, where the standard option for raw value is used +to specify the modified value. The following example demonstrates +a modification of MX record preference from 0 to 1 in a record without +modifying the exchanger: +ipa dnsrecord-mod --mx-rec="0 mx.example.com." --mx-preference=1 +""") + _(""" + +EXAMPLES: +""") + _(""" + Add new zone: + ipa dnszone-add example.com --admin-email=admin@example.com +""") + _(""" + Add system permission that can be used for per-zone privilege delegation: + ipa dnszone-add-permission example.com +""") + _(""" + Modify the zone to allow dynamic updates for hosts own records in realm EXAMPLE.COM: + ipa dnszone-mod example.com --dynamic-update=TRUE +""") + _(""" + This is the equivalent of: + ipa dnszone-mod example.com --dynamic-update=TRUE \\ + --update-policy="grant EXAMPLE.COM krb5-self * A; grant EXAMPLE.COM krb5-self * AAAA; grant EXAMPLE.COM krb5-self * SSHFP;" +""") + _(""" + Modify the zone to allow zone transfers for local network only: + ipa dnszone-mod example.com --allow-transfer=192.0.2.0/24 +""") + _(""" + Add new reverse zone specified by network IP address: + ipa dnszone-add --name-from-ip=192.0.2.0/24 +""") + _(""" + Add second nameserver for example.com: + ipa dnsrecord-add example.com @ --ns-rec=nameserver2.example.com +""") + _(""" + Add a mail server for example.com: + ipa dnsrecord-add example.com @ --mx-rec="10 mail1" +""") + _(""" + Add another record using MX record specific options: + ipa dnsrecord-add example.com @ --mx-preference=20 --mx-exchanger=mail2 +""") + _(""" + Add another record using interactive mode (started when dnsrecord-add, dnsrecord-mod, + or dnsrecord-del are executed with no options): + ipa dnsrecord-add example.com @ + Please choose a type of DNS resource record to be added + The most common types for this type of zone are: NS, MX, LOC + + DNS resource record type: MX + MX Preference: 30 + MX Exchanger: mail3 + Record name: example.com + MX record: 10 mail1, 20 mail2, 30 mail3 + NS record: nameserver.example.com., nameserver2.example.com. +""") + _(""" + Delete previously added nameserver from example.com: + ipa dnsrecord-del example.com @ --ns-rec=nameserver2.example.com. +""") + _(""" + Add LOC record for example.com: + ipa dnsrecord-add example.com @ --loc-rec="49 11 42.4 N 16 36 29.6 E 227.64m" +""") + _(""" + Add new A record for www.example.com. Create a reverse record in appropriate + reverse zone as well. In this case a PTR record "2" pointing to www.example.com + will be created in zone 2.0.192.in-addr.arpa. + ipa dnsrecord-add example.com www --a-rec=192.0.2.2 --a-create-reverse +""") + _(""" + Add new PTR record for www.example.com + ipa dnsrecord-add 2.0.192.in-addr.arpa. 2 --ptr-rec=www.example.com. +""") + _(""" + Add new SRV records for LDAP servers. Three quarters of the requests + should go to fast.example.com, one quarter to slow.example.com. If neither + is available, switch to backup.example.com. + ipa dnsrecord-add example.com _ldap._tcp --srv-rec="0 3 389 fast.example.com" + ipa dnsrecord-add example.com _ldap._tcp --srv-rec="0 1 389 slow.example.com" + ipa dnsrecord-add example.com _ldap._tcp --srv-rec="1 1 389 backup.example.com" +""") + _(""" + The interactive mode can be used for easy modification: + ipa dnsrecord-mod example.com _ldap._tcp + No option to modify specific record provided. + Current DNS record contents: + + SRV record: 0 3 389 fast.example.com, 0 1 389 slow.example.com, 1 1 389 backup.example.com + + Modify SRV record '0 3 389 fast.example.com'? Yes/No (default No): + Modify SRV record '0 1 389 slow.example.com'? Yes/No (default No): y + SRV Priority [0]: (keep the default value) + SRV Weight [1]: 2 (modified value) + SRV Port [389]: (keep the default value) + SRV Target [slow.example.com]: (keep the default value) + 1 SRV record skipped. Only one value per DNS record type can be modified at one time. + Record name: _ldap._tcp + SRV record: 0 3 389 fast.example.com, 1 1 389 backup.example.com, 0 2 389 slow.example.com +""") + _(""" + After this modification, three fifths of the requests should go to + fast.example.com and two fifths to slow.example.com. +""") + _(""" + An example of the interactive mode for dnsrecord-del command: + ipa dnsrecord-del example.com www + No option to delete specific record provided. + Delete all? Yes/No (default No): (do not delete all records) + Current DNS record contents: + + A record: 192.0.2.2, 192.0.2.3 + + Delete A record '192.0.2.2'? Yes/No (default No): + Delete A record '192.0.2.3'? Yes/No (default No): y + Record name: www + A record: 192.0.2.2 (A record 192.0.2.3 has been deleted) +""") + _(""" + Show zone example.com: + ipa dnszone-show example.com +""") + _(""" + Find zone with "example" in its domain name: + ipa dnszone-find example +""") + _(""" + Find records for resources with "www" in their name in zone example.com: + ipa dnsrecord-find example.com www +""") + _(""" + Find A records with value 192.0.2.2 in zone example.com + ipa dnsrecord-find example.com --a-rec=192.0.2.2 +""") + _(""" + Show records for resource www in zone example.com + ipa dnsrecord-show example.com www +""") + _(""" + Delegate zone sub.example to another nameserver: + ipa dnsrecord-add example.com ns.sub --a-rec=203.0.113.1 + ipa dnsrecord-add example.com sub --ns-rec=ns.sub.example.com. +""") + _(""" + Delete zone example.com with all resource records: + ipa dnszone-del example.com +""") + _(""" + If a global forwarder is configured, all queries for which this server is not + authoritative (e.g. sub.example.com) will be routed to the global forwarder. + Global forwarding configuration can be overridden per-zone. +""") + _(""" + Semantics of forwarding in IPA matches BIND semantics and depends on the type + of zone: + * Master zone: local BIND replies authoritatively to queries for data in + the given zone (including authoritative NXDOMAIN answers) and forwarding + affects only queries for names below zone cuts (NS records) of locally + served zones. + + * Forward zone: forward zone contains no authoritative data. BIND forwards + queries, which cannot be answered from its local cache, to configured + forwarders. +""") + _(""" + Semantics of the --forwarder-policy option: + * none - disable forwarding for the given zone. + * first - forward all queries to configured forwarders. If they fail, + do resolution using DNS root servers. + * only - forward all queries to configured forwarders and if they fail, + return failure. +""") + _(""" + Disable global forwarding for given sub-tree: + ipa dnszone-mod example.com --forward-policy=none +""") + _(""" + This configuration forwards all queries for names outside the example.com + sub-tree to global forwarders. Normal recursive resolution process is used + for names inside the example.com sub-tree (i.e. NS records are followed etc.). +""") + _(""" + Forward all requests for the zone external.example.com to another forwarder + using a "first" policy (it will send the queries to the selected forwarder + and if not answered it will use global root servers): + ipa dnsforwardzone-add external.example.com --forward-policy=first \\ + --forwarder=203.0.113.1 +""") + _(""" + Change forward-policy for external.example.com: + ipa dnsforwardzone-mod external.example.com --forward-policy=only +""") + _(""" + Show forward zone external.example.com: + ipa dnsforwardzone-show external.example.com +""") + _(""" + List all forward zones: + ipa dnsforwardzone-find +""") + _(""" + Delete forward zone external.example.com: + ipa dnsforwardzone-del external.example.com +""") + _(""" + Resolve a host name to see if it exists (will add default IPA domain + if one is not included): + ipa dns-resolve www.example.com + ipa dns-resolve www +""") + _(""" + +GLOBAL DNS CONFIGURATION +""") + _(""" +DNS configuration passed to command line install script is stored in a local +configuration file on each IPA server where DNS service is configured. These +local settings can be overridden with a common configuration stored in LDAP +server: +""") + _(""" + Show global DNS configuration: + ipa dnsconfig-show +""") + _(""" + Modify global DNS configuration and set a list of global forwarders: + ipa dnsconfig-mod --forwarder=203.0.113.113 +""") + +register = Registry() + +# supported resource record types +_record_types = ( + u'A', u'AAAA', u'A6', u'AFSDB', u'APL', u'CERT', u'CNAME', u'DHCID', u'DLV', + u'DNAME', u'DS', u'HIP', u'HINFO', u'IPSECKEY', u'KEY', u'KX', u'LOC', + u'MD', u'MINFO', u'MX', u'NAPTR', u'NS', u'NSEC', u'NXT', u'PTR', u'RRSIG', + u'RP', u'SIG', u'SPF', u'SRV', u'SSHFP', u'TLSA', u'TXT', +) + +# DNS zone record identificator +_dns_zone_record = DNSName.empty + +# attributes derived from record types +_record_attributes = [str(record_name_format % t.lower()) + for t in _record_types] + +# Deprecated +# supported DNS classes, IN = internet, rest is almost never used +_record_classes = (u'IN', u'CS', u'CH', u'HS') + +# IN record class +_IN = dns.rdataclass.IN + +# NS record type +_NS = dns.rdatatype.from_text('NS') + +_output_permissions = ( + output.summary, + output.Output('result', bool, _('True means the operation was successful')), + output.Output('value', unicode, _('Permission value')), +) + + +def _rname_validator(ugettext, zonemgr): + try: + DNSName(zonemgr) # test only if it is valid domain name + except (ValueError, dns.exception.SyntaxError) as e: + return unicode(e) + return None + +def _create_zone_serial(): + """ + Generate serial number for zones. bind-dyndb-ldap expects unix time in + to be used for SOA serial. + + SOA serial in a date format would also work, but it may be set to far + future when many DNS updates are done per day (more than 100). Unix + timestamp is more resilient to this issue. + """ + return int(time.time()) + +def _reverse_zone_name(netstr): + try: + netaddr.IPAddress(str(netstr)) + except (netaddr.AddrFormatError, ValueError): + pass + else: + # use more sensible default prefix than netaddr default + return unicode(get_reverse_zone_default(netstr)) + + net = netaddr.IPNetwork(netstr) + items = net.ip.reverse_dns.split('.') + if net.version == 4: + return u'.'.join(items[4 - net.prefixlen // 8:]) + elif net.version == 6: + return u'.'.join(items[32 - net.prefixlen // 4:]) + else: + return None + +def _validate_ipaddr(ugettext, ipaddr, ip_version=None): + try: + ip = netaddr.IPAddress(str(ipaddr), flags=netaddr.INET_PTON) + + if ip_version is not None: + if ip.version != ip_version: + return _('invalid IP address version (is %(value)d, must be %(required_value)d)!') \ + % dict(value=ip.version, required_value=ip_version) + except (netaddr.AddrFormatError, ValueError): + return _('invalid IP address format') + return None + +def _validate_ip4addr(ugettext, ipaddr): + return _validate_ipaddr(ugettext, ipaddr, 4) + +def _validate_ip6addr(ugettext, ipaddr): + return _validate_ipaddr(ugettext, ipaddr, 6) + +def _validate_ipnet(ugettext, ipnet): + try: + net = netaddr.IPNetwork(ipnet) + except (netaddr.AddrFormatError, ValueError, UnboundLocalError): + return _('invalid IP network format') + return None + +def _validate_bind_aci(ugettext, bind_acis): + if not bind_acis: + return + + bind_acis = bind_acis.split(';') + if bind_acis[-1]: + return _('each ACL element must be terminated with a semicolon') + else: + bind_acis.pop(-1) + + for bind_aci in bind_acis: + if bind_aci in ("any", "none", "localhost", "localnets"): + continue + + if bind_aci.startswith('!'): + bind_aci = bind_aci[1:] + + try: + ip = CheckedIPAddress(bind_aci, parse_netmask=True, + allow_network=True, allow_loopback=True) + except (netaddr.AddrFormatError, ValueError) as e: + return unicode(e) + except UnboundLocalError: + return _(u"invalid address format") + +def _normalize_bind_aci(bind_acis): + if not bind_acis: + return + bind_acis = bind_acis.split(';') + normalized = [] + for bind_aci in bind_acis: + if not bind_aci: + continue + if bind_aci in ("any", "none", "localhost", "localnets"): + normalized.append(bind_aci) + continue + + prefix = "" + if bind_aci.startswith('!'): + bind_aci = bind_aci[1:] + prefix = "!" + + try: + ip = CheckedIPAddress(bind_aci, parse_netmask=True, + allow_network=True, allow_loopback=True) + if '/' in bind_aci: # addr with netmask + netmask = "/%s" % ip.prefixlen + else: + netmask = "" + normalized.append(u"%s%s%s" % (prefix, str(ip), netmask)) + continue + except Exception: + normalized.append(bind_aci) + continue + + acis = u';'.join(normalized) + acis += u';' + return acis + +def _validate_bind_forwarder(ugettext, forwarder): + ip_address, sep, port = forwarder.partition(u' port ') + + ip_address_validation = _validate_ipaddr(ugettext, ip_address) + + if ip_address_validation is not None: + return ip_address_validation + + if sep: + try: + port = int(port) + if port < 0 or port > 65535: + raise ValueError() + except ValueError: + return _('%(port)s is not a valid port' % dict(port=port)) + + return None + +def _validate_nsec3param_record(ugettext, value): + _nsec3param_pattern = (r'^(?P<alg>\d+) (?P<flags>\d+) (?P<iter>\d+) ' + r'(?P<salt>([0-9a-fA-F]{2})+|-)$') + rec = re.compile(_nsec3param_pattern, flags=re.U) + result = rec.match(value) + + if result is None: + return _(u'expected format: <0-255> <0-255> <0-65535> ' + 'even-length_hexadecimal_digits_or_hyphen') + + alg = int(result.group('alg')) + flags = int(result.group('flags')) + iterations = int(result.group('iter')) + salt = result.group('salt') + + if alg > 255: + return _('algorithm value: allowed interval 0-255') + + if flags > 255: + return _('flags value: allowed interval 0-255') + + if iterations > 65535: + return _('iterations value: allowed interval 0-65535') + + if salt == u'-': + return None + + try: + binascii.a2b_hex(salt) + except TypeError as e: + return _('salt value: %(err)s') % {'err': e} + return None + + +def _hostname_validator(ugettext, value): + assert isinstance(value, DNSName) + if len(value.make_absolute().labels) < 3: + return _('invalid domain-name: not fully qualified') + + return None + +def _no_wildcard_validator(ugettext, value): + """Disallow usage of wildcards as RFC 4592 section 4 recommends + """ + assert isinstance(value, DNSName) + if value.is_wild(): + return _('should not be a wildcard domain name (RFC 4592 section 4)') + return None + +def is_forward_record(zone, str_address): + addr = netaddr.IPAddress(str_address) + if addr.version == 4: + result = api.Command['dnsrecord_find'](zone, arecord=str_address) + elif addr.version == 6: + result = api.Command['dnsrecord_find'](zone, aaaarecord=str_address) + else: + raise ValueError('Invalid address family') + + return result['count'] > 0 + +def add_forward_record(zone, name, str_address): + addr = netaddr.IPAddress(str_address) + try: + if addr.version == 4: + api.Command['dnsrecord_add'](zone, name, arecord=str_address) + elif addr.version == 6: + api.Command['dnsrecord_add'](zone, name, aaaarecord=str_address) + else: + raise ValueError('Invalid address family') + except errors.EmptyModlist: + pass # the entry already exists and matches + +def get_reverse_zone(ipaddr): + """ + resolve the reverse zone for IP address and see if it is managed by IPA + server + :param ipaddr: host IP address + :return: tuple containing name of the reverse zone and the name of the + record + """ + ip = netaddr.IPAddress(str(ipaddr)) + revdns = DNSName(unicode(ip.reverse_dns)) + revzone = DNSName(dns.resolver.zone_for_name(revdns)) + + try: + api.Command['dnszone_show'](revzone) + except errors.NotFound: + raise errors.NotFound( + reason=_( + 'DNS reverse zone %(revzone)s for IP address ' + '%(addr)s is not managed by this server') % dict( + addr=ipaddr, revzone=revzone) + ) + + revname = revdns.relativize(revzone) + + return revzone, revname + +def add_records_for_host_validation(option_name, host, domain, ip_addresses, check_forward=True, check_reverse=True): + assert isinstance(host, DNSName) + assert isinstance(domain, DNSName) + + try: + api.Command['dnszone_show'](domain)['result'] + except errors.NotFound: + raise errors.NotFound( + reason=_('DNS zone %(zone)s not found') % dict(zone=domain) + ) + if not isinstance(ip_addresses, (tuple, list)): + ip_addresses = [ip_addresses] + + for ip_address in ip_addresses: + try: + ip = CheckedIPAddress(ip_address, match_local=False) + except Exception as e: + raise errors.ValidationError(name=option_name, error=unicode(e)) + + if check_forward: + if is_forward_record(domain, unicode(ip)): + raise errors.DuplicateEntry( + message=_(u'IP address %(ip)s is already assigned in domain %(domain)s.')\ + % dict(ip=str(ip), domain=domain)) + + if check_reverse: + try: + # we prefer lookup of the IP through the reverse zone + revzone, revname = get_reverse_zone(ip) + reverse = api.Command['dnsrecord_find'](revzone, idnsname=revname) + if reverse['count'] > 0: + raise errors.DuplicateEntry( + message=_(u'Reverse record for IP address %(ip)s already exists in reverse zone %(zone)s.')\ + % dict(ip=str(ip), zone=revzone)) + except errors.NotFound: + pass + + +def add_records_for_host(host, domain, ip_addresses, add_forward=True, add_reverse=True): + assert isinstance(host, DNSName) + assert isinstance(domain, DNSName) + + if not isinstance(ip_addresses, (tuple, list)): + ip_addresses = [ip_addresses] + + for ip_address in ip_addresses: + ip = CheckedIPAddress(ip_address, match_local=False) + + if add_forward: + add_forward_record(domain, host, unicode(ip)) + + if add_reverse: + try: + revzone, revname = get_reverse_zone(ip) + addkw = {'ptrrecord': host.derelativize(domain).ToASCII()} + api.Command['dnsrecord_add'](revzone, revname, **addkw) + except errors.EmptyModlist: + # the entry already exists and matches + pass + +def _dns_name_to_string(value, raw=False): + if isinstance(value, unicode): + try: + value = DNSName(value) + except Exception: + return value + + assert isinstance(value, DNSName) + if raw: + return value.ToASCII() + else: + return unicode(value) + + +def _check_entry_objectclass(entry, objectclasses): + """ + Check if entry contains all objectclasses + """ + if not isinstance(objectclasses, (list, tuple)): + objectclasses = [objectclasses, ] + if not entry.get('objectclass'): + return False + entry_objectclasses = [o.lower() for o in entry['objectclass']] + for o in objectclasses: + if o not in entry_objectclasses: + return False + return True + + +def _check_DN_objectclass(ldap, dn, objectclasses): + try: + entry = ldap.get_entry(dn, [u'objectclass', ]) + except Exception: + return False + else: + return _check_entry_objectclass(entry, objectclasses) + + +class DNSRecord(Str): + # a list of parts that create the actual raw DNS record + parts = None + # an optional list of parameters used in record-specific operations + extra = None + supported = True + # supported RR types: https://fedorahosted.org/bind-dyndb-ldap/browser/doc/schema + + label_format = _("%s record") + part_label_format = "%s %s" + doc_format = _('Raw %s records') + option_group_format = _('%s Record') + see_rfc_msg = _("(see RFC %s for details)") + part_name_format = "%s_part_%s" + extra_name_format = "%s_extra_%s" + cli_name_format = "%s_%s" + format_error_msg = None + + kwargs = Str.kwargs + ( + ('validatedns', bool, True), + ('normalizedns', bool, True), + ) + + # should be replaced in subclasses + rrtype = None + rfc = None + + def __init__(self, name=None, *rules, **kw): + if self.rrtype not in _record_types: + raise ValueError("Unknown RR type: %s. Must be one of %s" % \ + (str(self.rrtype), ", ".join(_record_types))) + if not name: + name = "%s*" % (record_name_format % self.rrtype.lower()) + kw.setdefault('cli_name', '%s_rec' % self.rrtype.lower()) + kw.setdefault('label', self.label_format % self.rrtype) + kw.setdefault('doc', self.doc_format % self.rrtype) + kw.setdefault('option_group', self.option_group_format % self.rrtype) + + if not self.supported: + kw['flags'] = ('no_option',) + + super(DNSRecord, self).__init__(name, *rules, **kw) + + def _get_part_values(self, value): + values = value.split() + if len(values) != len(self.parts): + return None + return tuple(values) + + def _part_values_to_string(self, values, idna=True): + self._validate_parts(values) + parts = [] + for v in values: + if v is None: + continue + elif isinstance(v, DNSName) and idna: + v = v.ToASCII() + elif not isinstance(v, unicode): + v = unicode(v) + parts.append(v) + + return u" ".join(parts) + + def get_parts_from_kw(self, kw, raise_on_none=True): + part_names = tuple(self.part_name_format % (self.rrtype.lower(), part.name) \ + for part in self.parts) + vals = tuple(kw.get(part_name) for part_name in part_names) + + if all(val is None for val in vals): + return + + if raise_on_none: + for val_id,val in enumerate(vals): + if val is None and self.parts[val_id].required: + cli_name = self.cli_name_format % (self.rrtype.lower(), self.parts[val_id].name) + raise errors.ConversionError(name=self.name, + error=_("'%s' is a required part of DNS record") % cli_name) + + return vals + + def _validate_parts(self, parts): + if len(parts) != len(self.parts): + raise errors.ValidationError(name=self.name, + error=_("Invalid number of parts!")) + + def _convert_scalar(self, value, index=None): + if isinstance(value, (tuple, list)): + return self._part_values_to_string(value) + return super(DNSRecord, self)._convert_scalar(value) + + def normalize(self, value): + if self.normalizedns: + if isinstance(value, (tuple, list)): + value = tuple( + self._normalize_parts(v) for v in value \ + if v is not None + ) + elif value is not None: + value = (self._normalize_parts(value),) + + return super(DNSRecord, self).normalize(value) + + def _normalize_parts(self, value): + """ + Normalize a DNS record value using normalizers for its parts. + """ + if self.parts is None: + return value + try: + values = self._get_part_values(value) + if not values: + return value + + converted_values = [ part._convert_scalar(values[part_id]) \ + if values[part_id] is not None else None + for part_id, part in enumerate(self.parts) + ] + + new_values = [ part.normalize(converted_values[part_id]) \ + for part_id, part in enumerate(self.parts) ] + + value = self._convert_scalar(new_values) + except Exception: + # cannot normalize, rather return original value than fail + pass + return value + + def _rule_validatedns(self, _, value): + if not self.validatedns: + return + + if value is None: + return + + if not self.supported: + return _('DNS RR type "%s" is not supported by bind-dyndb-ldap plugin') \ + % self.rrtype + + if self.parts is None: + return + + # validate record format + values = self._get_part_values(value) + if not values: + if not self.format_error_msg: + part_names = [part.name.upper() for part in self.parts] + + if self.rfc: + see_rfc_msg = " " + self.see_rfc_msg % self.rfc + else: + see_rfc_msg = "" + return _('format must be specified as "%(format)s" %(rfcs)s') \ + % dict(format=" ".join(part_names), rfcs=see_rfc_msg) + else: + return self.format_error_msg + + # validate every part + for part_id, part in enumerate(self.parts): + val = part.normalize(values[part_id]) + val = part.convert(val) + part.validate(val) + return None + + def _convert_dnsrecord_part(self, part): + """ + All parts of DNSRecord need to be processed and modified before they + can be added to global DNS API. For example a prefix need to be added + before part name so that the name is unique in the global namespace. + """ + name = self.part_name_format % (self.rrtype.lower(), part.name) + cli_name = self.cli_name_format % (self.rrtype.lower(), part.name) + label = self.part_label_format % (self.rrtype, unicode(part.label)) + option_group = self.option_group_format % self.rrtype + flags = list(part.flags) + ['dnsrecord_part', 'virtual_attribute',] + if not part.required: + flags.append('dnsrecord_optional') + if not self.supported: + flags.append("no_option") + + return part.clone_rename(name, + cli_name=cli_name, + label=label, + required=False, + option_group=option_group, + flags=flags, + hint=self.name,) # name of parent RR param + + def _convert_dnsrecord_extra(self, extra): + """ + Parameters for special per-type behavior need to be processed in the + same way as record parts in _convert_dnsrecord_part(). + """ + name = self.extra_name_format % (self.rrtype.lower(), extra.name) + cli_name = self.cli_name_format % (self.rrtype.lower(), extra.name) + label = self.part_label_format % (self.rrtype, unicode(extra.label)) + option_group = self.option_group_format % self.rrtype + flags = list(extra.flags) + ['dnsrecord_extra', 'virtual_attribute',] + + return extra.clone_rename(name, + cli_name=cli_name, + label=label, + required=False, + option_group=option_group, + flags=flags, + hint=self.name,) # name of parent RR param + + def get_parts(self): + if self.parts is None: + return tuple() + + return tuple(self._convert_dnsrecord_part(part) for part in self.parts) + + def get_extra(self): + if self.extra is None: + return tuple() + + return tuple(self._convert_dnsrecord_extra(extra) for extra in self.extra) + + # callbacks for per-type special record behavior + def dnsrecord_add_pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + def dnsrecord_add_post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + +class ForwardRecord(DNSRecord): + extra = ( + Flag('create_reverse?', + label=_('Create reverse'), + doc=_('Create reverse record for this IP Address'), + flags=['no_update'] + ), + ) + + def dnsrecord_add_pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + reverse_option = self._convert_dnsrecord_extra(self.extra[0]) + if options.get(reverse_option.name): + records = entry_attrs.get(self.name, []) + if not records: + # --<rrtype>-create-reverse is set, but there are not records + raise errors.RequirementError(name=self.name) + + for record in records: + add_records_for_host_validation(self.name, keys[-1], keys[-2], record, + check_forward=False, + check_reverse=True) + + setattr(context, '%s_reverse' % self.name, entry_attrs.get(self.name)) + + def dnsrecord_add_post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + rev_records = getattr(context, '%s_reverse' % self.name, []) + + if rev_records: + # make sure we don't run this post callback action again in nested + # commands, line adding PTR record in add_records_for_host + delattr(context, '%s_reverse' % self.name) + for record in rev_records: + try: + add_records_for_host(keys[-1], keys[-2], record, + add_forward=False, add_reverse=True) + except Exception as e: + raise errors.NonFatalError( + reason=_('Cannot create reverse record for "%(value)s": %(exc)s') \ + % dict(value=record, exc=unicode(e))) + +class UnsupportedDNSRecord(DNSRecord): + """ + Records which are not supported by IPA CLI, but we allow to show them if + LDAP contains these records. + """ + supported = False + + def _get_part_values(self, value): + return tuple() + + +class ARecord(ForwardRecord): + rrtype = 'A' + rfc = 1035 + parts = ( + Str('ip_address', + _validate_ip4addr, + label=_('IP Address'), + ), + ) + +class A6Record(DNSRecord): + rrtype = 'A6' + rfc = 3226 + parts = ( + Str('data', + label=_('Record data'), + ), + ) + + def _get_part_values(self, value): + # A6 RR type is obsolete and only a raw interface is provided + return (value,) + +class AAAARecord(ForwardRecord): + rrtype = 'AAAA' + rfc = 3596 + parts = ( + Str('ip_address', + _validate_ip6addr, + label=_('IP Address'), + ), + ) + +class AFSDBRecord(DNSRecord): + rrtype = 'AFSDB' + rfc = 1183 + parts = ( + Int('subtype?', + label=_('Subtype'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('hostname', + label=_('Hostname'), + ), + ) + +class APLRecord(UnsupportedDNSRecord): + rrtype = 'APL' + rfc = 3123 + +class CERTRecord(DNSRecord): + rrtype = 'CERT' + rfc = 4398 + parts = ( + Int('type', + label=_('Certificate Type'), + minvalue=0, + maxvalue=65535, + ), + Int('key_tag', + label=_('Key Tag'), + minvalue=0, + maxvalue=65535, + ), + Int('algorithm', + label=_('Algorithm'), + minvalue=0, + maxvalue=255, + ), + Str('certificate_or_crl', + label=_('Certificate/CRL'), + ), + ) + +class CNAMERecord(DNSRecord): + rrtype = 'CNAME' + rfc = 1035 + parts = ( + DNSNameParam('hostname', + label=_('Hostname'), + doc=_('A hostname which this alias hostname points to'), + ), + ) + +class DHCIDRecord(UnsupportedDNSRecord): + rrtype = 'DHCID' + rfc = 4701 + +class DNAMERecord(DNSRecord): + rrtype = 'DNAME' + rfc = 2672 + parts = ( + DNSNameParam('target', + label=_('Target'), + ), + ) + + +class DSRecord(DNSRecord): + rrtype = 'DS' + rfc = 4034 + parts = ( + Int('key_tag', + label=_('Key Tag'), + minvalue=0, + maxvalue=65535, + ), + Int('algorithm', + label=_('Algorithm'), + minvalue=0, + maxvalue=255, + ), + Int('digest_type', + label=_('Digest Type'), + minvalue=0, + maxvalue=255, + ), + Str('digest', + label=_('Digest'), + pattern=r'^[0-9a-fA-F]+$', + pattern_errmsg=u'only hexadecimal digits are allowed' + ), + ) + + +class DLVRecord(DSRecord): + # must use same attributes as DSRecord + rrtype = 'DLV' + rfc = 4431 + + +class HINFORecord(UnsupportedDNSRecord): + rrtype = 'HINFO' + rfc = 1035 + + +class HIPRecord(UnsupportedDNSRecord): + rrtype = 'HIP' + rfc = 5205 + +class KEYRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'KEY' + rfc = 2535 + +class IPSECKEYRecord(UnsupportedDNSRecord): + rrtype = 'IPSECKEY' + rfc = 4025 + +class KXRecord(DNSRecord): + rrtype = 'KX' + rfc = 2230 + parts = ( + Int('preference', + label=_('Preference'), + doc=_('Preference given to this exchanger. Lower values are more preferred'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('exchanger', + label=_('Exchanger'), + doc=_('A host willing to act as a key exchanger'), + ), + ) + +class LOCRecord(DNSRecord): + rrtype = 'LOC' + rfc = 1876 + parts = ( + Int('lat_deg', + label=_('Degrees Latitude'), + minvalue=0, + maxvalue=90, + ), + Int('lat_min?', + label=_('Minutes Latitude'), + minvalue=0, + maxvalue=59, + ), + Decimal('lat_sec?', + label=_('Seconds Latitude'), + minvalue='0.0', + maxvalue='59.999', + precision=3, + ), + StrEnum('lat_dir', + label=_('Direction Latitude'), + values=(u'N', u'S',), + ), + Int('lon_deg', + label=_('Degrees Longitude'), + minvalue=0, + maxvalue=180, + ), + Int('lon_min?', + label=_('Minutes Longitude'), + minvalue=0, + maxvalue=59, + ), + Decimal('lon_sec?', + label=_('Seconds Longitude'), + minvalue='0.0', + maxvalue='59.999', + precision=3, + ), + StrEnum('lon_dir', + label=_('Direction Longitude'), + values=(u'E', u'W',), + ), + Decimal('altitude', + label=_('Altitude'), + minvalue='-100000.00', + maxvalue='42849672.95', + precision=2, + ), + Decimal('size?', + label=_('Size'), + minvalue='0.0', + maxvalue='90000000.00', + precision=2, + ), + Decimal('h_precision?', + label=_('Horizontal Precision'), + minvalue='0.0', + maxvalue='90000000.00', + precision=2, + ), + Decimal('v_precision?', + label=_('Vertical Precision'), + minvalue='0.0', + maxvalue='90000000.00', + precision=2, + ), + ) + + format_error_msg = _("""format must be specified as + "d1 [m1 [s1]] {"N"|"S"} d2 [m2 [s2]] {"E"|"W"} alt["m"] [siz["m"] [hp["m"] [vp["m"]]]]" + where: + d1: [0 .. 90] (degrees latitude) + d2: [0 .. 180] (degrees longitude) + m1, m2: [0 .. 59] (minutes latitude/longitude) + s1, s2: [0 .. 59.999] (seconds latitude/longitude) + alt: [-100000.00 .. 42849672.95] BY .01 (altitude in meters) + siz, hp, vp: [0 .. 90000000.00] (size/precision in meters) + See RFC 1876 for details""") + + def _get_part_values(self, value): + regex = re.compile( + r'(?P<d1>\d{1,2}\s+)' + r'(?:(?P<m1>\d{1,2}\s+)' + r'(?P<s1>\d{1,2}(?:\.\d{1,3})?\s+)?)?' + r'(?P<dir1>[NS])\s+' + r'(?P<d2>\d{1,3}\s+)' + r'(?:(?P<m2>\d{1,2}\s+)' + r'(?P<s2>\d{1,2}(?:\.\d{1,3})?\s+)?)?' + r'(?P<dir2>[WE])\s+' + r'(?P<alt>-?\d{1,8}(?:\.\d{1,2})?)m?' + r'(?:\s+(?P<siz>\d{1,8}(?:\.\d{1,2})?)m?' + r'(?:\s+(?P<hp>\d{1,8}(?:\.\d{1,2})?)m?' + r'(?:\s+(?P<vp>\d{1,8}(?:\.\d{1,2})?)m?\s*)?)?)?$') + + m = regex.match(value) + + if m is None: + return None + + return tuple(x.strip() if x is not None else x for x in m.groups()) + + def _validate_parts(self, parts): + super(LOCRecord, self)._validate_parts(parts) + + # create part_name -> part_id map first + part_name_map = dict((part.name, part_id) \ + for part_id,part in enumerate(self.parts)) + + requirements = ( ('lat_sec', 'lat_min'), + ('lon_sec', 'lon_min'), + ('h_precision', 'size'), + ('v_precision', 'h_precision', 'size') ) + + for req in requirements: + target_part = req[0] + + if parts[part_name_map[target_part]] is not None: + required_parts = req[1:] + if any(parts[part_name_map[part]] is None for part in required_parts): + target_cli_name = self.cli_name_format % (self.rrtype.lower(), req[0]) + required_cli_names = [ self.cli_name_format % (self.rrtype.lower(), part) + for part in req[1:] ] + error = _("'%(required)s' must not be empty when '%(name)s' is set") % \ + dict(required=', '.join(required_cli_names), + name=target_cli_name) + raise errors.ValidationError(name=self.name, error=error) + + +class MDRecord(UnsupportedDNSRecord): + # obsoleted, use MX instead + rrtype = 'MD' + rfc = 1035 + + +class MINFORecord(UnsupportedDNSRecord): + rrtype = 'MINFO' + rfc = 1035 + + +class MXRecord(DNSRecord): + rrtype = 'MX' + rfc = 1035 + parts = ( + Int('preference', + label=_('Preference'), + doc=_('Preference given to this exchanger. Lower values are more preferred'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('exchanger', + label=_('Exchanger'), + doc=_('A host willing to act as a mail exchanger'), + ), + ) + +class NSRecord(DNSRecord): + rrtype = 'NS' + rfc = 1035 + + parts = ( + DNSNameParam('hostname', + label=_('Hostname'), + ), + ) + +class NSECRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'NSEC' + rfc = 4034 + + +def _validate_naptr_flags(ugettext, flags): + allowed_flags = u'SAUP' + flags = flags.replace('"','').replace('\'','') + + for flag in flags: + if flag not in allowed_flags: + return _('flags must be one of "S", "A", "U", or "P"') + +class NAPTRRecord(DNSRecord): + rrtype = 'NAPTR' + rfc = 2915 + + parts = ( + Int('order', + label=_('Order'), + minvalue=0, + maxvalue=65535, + ), + Int('preference', + label=_('Preference'), + minvalue=0, + maxvalue=65535, + ), + Str('flags', + _validate_naptr_flags, + label=_('Flags'), + normalizer=lambda x:x.upper() + ), + Str('service', + label=_('Service'), + ), + Str('regexp', + label=_('Regular Expression'), + ), + Str('replacement', + label=_('Replacement'), + ), + ) + + +class NXTRecord(UnsupportedDNSRecord): + rrtype = 'NXT' + rfc = 2535 + + +class PTRRecord(DNSRecord): + rrtype = 'PTR' + rfc = 1035 + parts = ( + DNSNameParam('hostname', + #RFC 2317 section 5.2 -- can be relative + label=_('Hostname'), + doc=_('The hostname this reverse record points to'), + ), + ) + +class RPRecord(UnsupportedDNSRecord): + rrtype = 'RP' + rfc = 1183 + +class SRVRecord(DNSRecord): + rrtype = 'SRV' + rfc = 2782 + parts = ( + Int('priority', + label=_('Priority'), + minvalue=0, + maxvalue=65535, + ), + Int('weight', + label=_('Weight'), + minvalue=0, + maxvalue=65535, + ), + Int('port', + label=_('Port'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('target', + label=_('Target'), + doc=_('The domain name of the target host or \'.\' if the service is decidedly not available at this domain'), + ), + ) + +def _sig_time_validator(ugettext, value): + time_format = "%Y%m%d%H%M%S" + try: + time.strptime(value, time_format) + except ValueError: + return _('the value does not follow "YYYYMMDDHHMMSS" time format') + + +class SIGRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'SIG' + rfc = 2535 + +class SPFRecord(UnsupportedDNSRecord): + rrtype = 'SPF' + rfc = 4408 + +class RRSIGRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'RRSIG' + rfc = 4034 + +class SSHFPRecord(DNSRecord): + rrtype = 'SSHFP' + rfc = 4255 + parts = ( + Int('algorithm', + label=_('Algorithm'), + minvalue=0, + maxvalue=255, + ), + Int('fp_type', + label=_('Fingerprint Type'), + minvalue=0, + maxvalue=255, + ), + Str('fingerprint', + label=_('Fingerprint'), + ), + ) + + def _get_part_values(self, value): + # fingerprint part can contain space in LDAP, return it as one part + values = value.split(None, 2) + if len(values) != len(self.parts): + return None + return tuple(values) + + +class TLSARecord(DNSRecord): + rrtype = 'TLSA' + rfc = 6698 + parts = ( + Int('cert_usage', + label=_('Certificate Usage'), + minvalue=0, + maxvalue=255, + ), + Int('selector', + label=_('Selector'), + minvalue=0, + maxvalue=255, + ), + Int('matching_type', + label=_('Matching Type'), + minvalue=0, + maxvalue=255, + ), + Str('cert_association_data', + label=_('Certificate Association Data'), + ), + ) + + +class TXTRecord(DNSRecord): + rrtype = 'TXT' + rfc = 1035 + parts = ( + Str('data', + label=_('Text Data'), + ), + ) + + def _get_part_values(self, value): + # ignore any space in TXT record + return (value,) + +_dns_records = ( + ARecord(), + AAAARecord(), + A6Record(), + AFSDBRecord(), + APLRecord(), + CERTRecord(), + CNAMERecord(), + DHCIDRecord(), + DLVRecord(), + DNAMERecord(), + DSRecord(), + HIPRecord(), + IPSECKEYRecord(), + KEYRecord(), + KXRecord(), + LOCRecord(), + MXRecord(), + NAPTRRecord(), + NSRecord(), + NSECRecord(), + PTRRecord(), + RRSIGRecord(), + RPRecord(), + SIGRecord(), + SPFRecord(), + SRVRecord(), + SSHFPRecord(), + TLSARecord(), + TXTRecord(), +) + +def __dns_record_options_iter(): + for opt in (Any('dnsrecords?', + label=_('Records'), + flags=['no_create', 'no_search', 'no_update'],), + Str('dnstype?', + label=_('Record type'), + flags=['no_create', 'no_search', 'no_update'],), + Str('dnsdata?', + label=_('Record data'), + flags=['no_create', 'no_search', 'no_update'],)): + # These 3 options are used in --structured format. They are defined + # rather in takes_params than has_output_params because of their + # order - they should be printed to CLI before any DNS part param + yield opt + for option in _dns_records: + yield option + + for part in option.get_parts(): + yield part + + for extra in option.get_extra(): + yield extra + +_dns_record_options = tuple(__dns_record_options_iter()) + + +def check_ns_rec_resolvable(zone, name, log): + assert isinstance(zone, DNSName) + assert isinstance(name, DNSName) + + if name.is_empty(): + name = zone.make_absolute() + elif not name.is_absolute(): + # this is a DNS name relative to the zone + name = name.derelativize(zone.make_absolute()) + try: + verify_host_resolvable(name) + except errors.DNSNotARecordError: + raise errors.NotFound( + reason=_('Nameserver \'%(host)s\' does not have a corresponding ' + 'A/AAAA record') % {'host': name} + ) + +def dns_container_exists(ldap): + try: + ldap.get_entry(DN(api.env.container_dns, api.env.basedn), []) + except errors.NotFound: + return False + return True + + +def dnssec_installed(ldap): + """ + * Method opendnssecinstance.get_dnssec_key_masters() CANNOT be used in the + dns plugin, or any plugin accessible for common users! * + Why?: The content of service container is not readable for common users. + + This method only try to find if a DNSSEC service container exists on any + replica. What means that DNSSEC key master is installed. + :param ldap: ldap connection + :return: True if DNSSEC was installed, otherwise False + """ + dn = DN(api.env.container_masters, api.env.basedn) + + filter_attrs = { + u'cn': u'DNSSEC', + u'objectclass': u'ipaConfigObject', + } + only_masters_f = ldap.make_filter(filter_attrs, rules=ldap.MATCH_ALL) + + try: + ldap.find_entries(filter=only_masters_f, base_dn=dn) + except errors.NotFound: + return False + return True + + +def default_zone_update_policy(zone): + if zone.is_reverse(): + return get_dns_reverse_zone_update_policy(api.env.realm, zone.ToASCII()) + else: + return get_dns_forward_zone_update_policy(api.env.realm) + +dnszone_output_params = ( + Str('managedby', + label=_('Managedby permission'), + ), +) + + +def _convert_to_idna(value): + """ + Function converts a unicode value to idna, without extra validation. + If conversion fails, None is returned + """ + assert isinstance(value, unicode) + + try: + idna_val = value + start_dot = u'' + end_dot = u'' + if idna_val.startswith(u'.'): + idna_val = idna_val[1:] + start_dot = u'.' + if idna_val.endswith(u'.'): + idna_val = idna_val[:-1] + end_dot = u'.' + idna_val = encodings.idna.nameprep(idna_val) + idna_val = re.split(r'(?<!\\)\.', idna_val) + idna_val = u'%s%s%s' % (start_dot, + u'.'.join(encodings.idna.ToASCII(x) + for x in idna_val), + end_dot) + return idna_val + except Exception: + pass + return None + + +def _create_idn_filter(cmd, ldap, term=None, **options): + if term: + #include idna values to search + term_idna = _convert_to_idna(term) + if term_idna and term != term_idna: + term = (term, term_idna) + + search_kw = {} + attr_extra_filters = [] + + for attr, value in cmd.args_options_2_entry(**options).items(): + if not isinstance(value, list): + value = [value] + for i, v in enumerate(value): + if isinstance(v, DNSName): + value[i] = v.ToASCII() + elif attr in map_names_to_records: + record = map_names_to_records[attr] + parts = record._get_part_values(v) + if parts is None: + value[i] = v + continue + try: + value[i] = record._part_values_to_string(parts) + except errors.ValidationError: + value[i] = v + + #create MATCH_ANY filter for multivalue + if len(value) > 1: + f = ldap.make_filter({attr: value}, rules=ldap.MATCH_ANY) + attr_extra_filters.append(f) + else: + search_kw[attr] = value + + if cmd.obj.search_attributes: + search_attrs = cmd.obj.search_attributes + else: + search_attrs = cmd.obj.default_attributes + if cmd.obj.search_attributes_config: + config = ldap.get_ipa_config() + config_attrs = config.get(cmd.obj.search_attributes_config, []) + if len(config_attrs) == 1 and (isinstance(config_attrs[0], + six.string_types)): + search_attrs = config_attrs[0].split(',') + + search_kw['objectclass'] = cmd.obj.object_class + attr_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL) + if attr_extra_filters: + #combine filter if there is any idna value + attr_extra_filters.append(attr_filter) + attr_filter = ldap.combine_filters(attr_extra_filters, + rules=ldap.MATCH_ALL) + + search_kw = {} + for a in search_attrs: + search_kw[a] = term + term_filter = ldap.make_filter(search_kw, exact=False) + + member_filter = cmd.get_member_filter(ldap, **options) + + filter = ldap.combine_filters( + (term_filter, attr_filter, member_filter), rules=ldap.MATCH_ALL + ) + return filter + + +map_names_to_records = {record_name_format % record.rrtype.lower(): record + for record in _dns_records if record.supported} + +def _records_idn_postprocess(record, **options): + for attr in record.keys(): + attr = attr.lower() + try: + param = map_names_to_records[attr] + except KeyError: + continue + if not isinstance(param, DNSRecord): + continue + + part_params = param.get_parts() + rrs = [] + for dnsvalue in record[attr]: + parts = param._get_part_values(dnsvalue) + if parts is None: + continue + parts = list(parts) + try: + for (i, p) in enumerate(parts): + if isinstance(part_params[i], DNSNameParam): + parts[i] = DNSName(p) + rrs.append(param._part_values_to_string(parts, + idna=options.get('raw', False))) + except (errors.ValidationError, errors.ConversionError): + rrs.append(dnsvalue) + record[attr] = rrs + +def _normalize_zone(zone): + if isinstance(zone, unicode): + # normalize only non-IDNA zones + try: + zone.encode('ascii') + except UnicodeError: + pass + else: + return zone.lower() + return zone + + +def _get_auth_zone_ldap(api, name): + """ + Find authoritative zone in LDAP for name. Only active zones are considered. + :param name: + :return: (zone, truncated) + zone: authoritative zone, or None if authoritative zone is not in LDAP + """ + assert isinstance(name, DNSName) + ldap = api.Backend.ldap2 + + # Create all possible parent zone names + search_name = name.make_absolute() + zone_names = [] + for i, name in enumerate(search_name): + zone_name_abs = DNSName(search_name[i:]).ToASCII() + zone_names.append(zone_name_abs) + # compatibility with IPA < 4.0, zone name can be relative + zone_names.append(zone_name_abs[:-1]) + + # Create filters + objectclass_filter = ldap.make_filter({'objectclass':'idnszone'}) + zonenames_filter = ldap.make_filter({'idnsname': zone_names}) + zoneactive_filter = ldap.make_filter({'idnsZoneActive': 'true'}) + complete_filter = ldap.combine_filters( + [objectclass_filter, zonenames_filter, zoneactive_filter], + rules=ldap.MATCH_ALL + ) + + try: + entries, truncated = ldap.find_entries( + filter=complete_filter, + attrs_list=['idnsname'], + base_dn=DN(api.env.container_dns, api.env.basedn), + scope=ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + return None, False + + # always use absolute zones + matched_auth_zones = [entry.single_value['idnsname'].make_absolute() + for entry in entries] + + # return longest match + return max(matched_auth_zones, key=len), truncated + + +def _get_longest_match_ns_delegation_ldap(api, zone, name): + """ + Searches for deepest delegation for name in LDAP zone. + + NOTE: NS record in zone apex is not considered as delegation. + It returns None if there is no delegation outside of zone apex. + + Example: + zone: example.com. + name: ns.sub.example.com. + + records: + extra.ns.sub.example.com. + sub.example.com. + example.com + + result: sub.example.com. + + :param zone: zone name + :param name: + :return: (match, truncated); + match: delegation name if success, or None if no delegation record exists + """ + assert isinstance(zone, DNSName) + assert isinstance(name, DNSName) + + ldap = api.Backend.ldap2 + + # get zone DN + zone_dn = api.Object.dnszone.get_dn(zone) + + if name.is_absolute(): + relative_record_name = name.relativize(zone.make_absolute()) + else: + relative_record_name = name + + # Name is zone apex + if relative_record_name.is_empty(): + return None, False + + # create list of possible record names + possible_record_names = [DNSName(relative_record_name[i:]).ToASCII() + for i in range(len(relative_record_name))] + + # search filters + name_filter = ldap.make_filter({'idnsname': [possible_record_names]}) + objectclass_filter = ldap.make_filter({'objectclass': 'idnsrecord'}) + complete_filter = ldap.combine_filters( + [name_filter, objectclass_filter], + rules=ldap.MATCH_ALL + ) + + try: + entries, truncated = ldap.find_entries( + filter=complete_filter, + attrs_list=['idnsname', 'nsrecord'], + base_dn=zone_dn, + scope=ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + return None, False + + matched_records = [] + + # test if entry contains NS records + for entry in entries: + if entry.get('nsrecord'): + matched_records.append(entry.single_value['idnsname']) + + if not matched_records: + return None, truncated + + # return longest match + return max(matched_records, key=len), truncated + + +def _find_subtree_forward_zones_ldap(api, name, child_zones_only=False): + """ + Search for forwardzone <name> and all child forwardzones + Filter: (|(*.<name>.)(<name>.)) + :param name: + :param child_zones_only: search only for child zones + :return: (list of zonenames, truncated), list is empty if no zone found + """ + assert isinstance(name, DNSName) + ldap = api.Backend.ldap2 + + # prepare for filter "*.<name>." + search_name = u".%s" % name.make_absolute().ToASCII() + + # we need to search zone with and without last dot, due compatibility + # with IPA < 4.0 + search_names = [search_name, search_name[:-1]] + + # Create filters + objectclass_filter = ldap.make_filter({'objectclass':'idnsforwardzone'}) + zonenames_filter = ldap.make_filter({'idnsname': search_names}, exact=False, + trailing_wildcard=False) + if not child_zones_only: + # find also zone with exact name + exact_name = name.make_absolute().ToASCII() + # we need to search zone with and without last dot, due compatibility + # with IPA < 4.0 + exact_names = [exact_name, exact_name[-1]] + exact_name_filter = ldap.make_filter({'idnsname': exact_names}) + zonenames_filter = ldap.combine_filters([zonenames_filter, + exact_name_filter]) + + zoneactive_filter = ldap.make_filter({'idnsZoneActive': 'true'}) + complete_filter = ldap.combine_filters( + [objectclass_filter, zonenames_filter, zoneactive_filter], + rules=ldap.MATCH_ALL + ) + + try: + entries, truncated = ldap.find_entries( + filter=complete_filter, + attrs_list=['idnsname'], + base_dn=DN(api.env.container_dns, api.env.basedn), + scope=ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + return [], False + + result = [entry.single_value['idnsname'].make_absolute() + for entry in entries] + + return result, truncated + + +def _get_zone_which_makes_fw_zone_ineffective(api, fwzonename): + """ + Check if forward zone is effective. + + If parent zone exists as authoritative zone, the forward zone will not + forward queries by default. It is necessary to delegate authority + to forward zone with a NS record. + + Example: + + Forward zone: sub.example.com + Zone: example.com + + Forwarding will not work, because the server thinks it is authoritative + for zone and will return NXDOMAIN + + Adding record: sub.example.com NS ns.sub.example.com. + will delegate authority, and IPA DNS server will forward DNS queries. + + :param fwzonename: forwardzone + :return: (zone, truncated) + zone: None if effective, name of authoritative zone otherwise + """ + assert isinstance(fwzonename, DNSName) + + auth_zone, truncated_zone = _get_auth_zone_ldap(api, fwzonename) + if not auth_zone: + return None, truncated_zone + + delegation_record_name, truncated_ns =\ + _get_longest_match_ns_delegation_ldap(api, auth_zone, fwzonename) + + truncated = truncated_ns or truncated_zone + + if delegation_record_name: + return None, truncated + + return auth_zone, truncated + + +def _add_warning_fw_zone_is_not_effective(api, result, fwzone, version): + """ + Adds warning message to result, if required + """ + authoritative_zone, truncated = \ + _get_zone_which_makes_fw_zone_ineffective(api, fwzone) + if authoritative_zone: + # forward zone is not effective and forwarding will not work + messages.add_message( + version, result, + messages.ForwardzoneIsNotEffectiveWarning( + fwzone=fwzone, authzone=authoritative_zone, + ns_rec=fwzone.relativize(authoritative_zone) + ) + ) + + +def _add_warning_fw_policy_conflict_aez(result, fwzone, **options): + """Warn if forwarding policy conflicts with an automatic empty zone.""" + fwd_policy = result['result'].get(u'idnsforwardpolicy', + dnsforwardzone.default_forward_policy) + if ( + fwd_policy != [u'only'] + and related_to_auto_empty_zone(DNSName(fwzone)) + ): + messages.add_message( + options['version'], result, + messages.DNSForwardPolicyConflictWithEmptyZone() + ) + + +class DNSZoneBase(LDAPObject): + """ + Base class for DNS Zone + """ + container_dn = api.env.container_dns + object_class = ['top'] + possible_objectclasses = ['ipadnszone'] + default_attributes = [ + 'idnsname', 'idnszoneactive', 'idnsforwarders', 'idnsforwardpolicy' + ] + + takes_params = ( + DNSNameParam('idnsname', + _no_wildcard_validator, # RFC 4592 section 4 + only_absolute=True, + cli_name='name', + label=_('Zone name'), + doc=_('Zone name (FQDN)'), + default_from=lambda name_from_ip: _reverse_zone_name(name_from_ip), + normalizer=_normalize_zone, + primary_key=True, + ), + Str('name_from_ip?', _validate_ipnet, + label=_('Reverse zone IP network'), + doc=_('IP network to create reverse zone name from'), + flags=('virtual_attribute',), + ), + Bool('idnszoneactive?', + cli_name='zone_active', + label=_('Active zone'), + doc=_('Is zone active?'), + flags=['no_create', 'no_update'], + attribute=True, + ), + Str('idnsforwarders*', + _validate_bind_forwarder, + cli_name='forwarder', + label=_('Zone forwarders'), + doc=_('Per-zone forwarders. A custom port can be specified ' + 'for each forwarder using a standard format "IP_ADDRESS port PORT"'), + ), + StrEnum('idnsforwardpolicy?', + cli_name='forward_policy', + label=_('Forward policy'), + doc=_('Per-zone conditional forwarding policy. Set to "none" to ' + 'disable forwarding to global forwarder for this zone. In ' + 'that case, conditional zone forwarders are disregarded.'), + values=(u'only', u'first', u'none'), + ), + + ) + + def get_dn(self, *keys, **options): + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + + zone = keys[-1] + assert isinstance(zone, DNSName) + assert zone.is_absolute() + zone_a = zone.ToASCII() + + # special case when zone is the root zone ('.') + if zone == DNSName.root: + return super(DNSZoneBase, self).get_dn(zone_a, **options) + + # try first relative name, a new zone has to be added as absolute + # otherwise ObjectViolation is raised + zone_a = zone_a[:-1] + dn = super(DNSZoneBase, self).get_dn(zone_a, **options) + try: + self.backend.get_entry(dn, ['']) + except errors.NotFound: + zone_a = u"%s." % zone_a + dn = super(DNSZoneBase, self).get_dn(zone_a, **options) + + return dn + + def permission_name(self, zone): + assert isinstance(zone, DNSName) + return u"Manage DNS zone %s" % zone.ToASCII() + + def get_name_in_zone(self, zone, hostname): + """ + Get name of a record that is to be added to a new zone. I.e. when + we want to add record "ipa.lab.example.com" in a zone "example.com", + this function should return "ipa.lab". Returns None when record cannot + be added to a zone. Returns '@' when the hostname is the zone record. + """ + assert isinstance(zone, DNSName) + assert zone.is_absolute() + assert isinstance(hostname, DNSName) + + if not hostname.is_absolute(): + return hostname + + if hostname.is_subdomain(zone): + return hostname.relativize(zone) + + return None + + def _remove_permission(self, zone): + permission_name = self.permission_name(zone) + try: + self.api.Command['permission_del'](permission_name, force=True) + except errors.NotFound as e: + if zone == DNSName.root: # special case root zone + raise + # compatibility, older IPA versions which allows to create zone + # without absolute zone name + permission_name_rel = self.permission_name( + zone.relativize(DNSName.root) + ) + try: + self.api.Command['permission_del'](permission_name_rel, + force=True) + except errors.NotFound: + raise e # re-raise original exception + + def _make_zonename_absolute(self, entry_attrs, **options): + """ + Zone names can be relative in IPA < 4.0, make sure we always return + absolute zone name from ldap + """ + if options.get('raw'): + return + + if "idnsname" in entry_attrs: + entry_attrs.single_value['idnsname'] = ( + entry_attrs.single_value['idnsname'].make_absolute()) + + +class DNSZoneBase_add(LDAPCreate): + + takes_options = LDAPCreate.takes_options + ( + Flag('skip_overlap_check', + doc=_('Force DNS zone creation even if it will overlap with ' + 'an existing zone.') + ), + ) + + has_output_params = LDAPCreate.has_output_params + dnszone_output_params + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + try: + entry = ldap.get_entry(dn) + except errors.NotFound: + pass + else: + if _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_duplicate_entry(*keys) + else: + raise errors.DuplicateEntry( + message=_(u'Only one zone type is allowed per zone name') + ) + + entry_attrs['idnszoneactive'] = 'TRUE' + + if not options['skip_overlap_check']: + try: + check_zone_overlap(keys[-1]) + except ValueError as e: + raise errors.InvocationError(e.message) + + return dn + + +class DNSZoneBase_del(LDAPDelete): + + def pre_callback(self, ldap, dn, *nkeys, **options): + assert isinstance(dn, DN) + if not _check_DN_objectclass(ldap, dn, self.obj.object_class): + self.obj.handle_not_found(*nkeys) + return dn + + def post_callback(self, ldap, dn, *keys, **options): + try: + self.obj._remove_permission(keys[-1]) + except errors.NotFound: + pass + + return True + + +class DNSZoneBase_mod(LDAPUpdate): + has_output_params = LDAPUpdate.has_output_params + dnszone_output_params + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj._make_zonename_absolute(entry_attrs, **options) + return dn + + +class DNSZoneBase_find(LDAPSearch): + __doc__ = _('Search for DNS zones (SOA records).') + + has_output_params = LDAPSearch.has_output_params + dnszone_output_params + + def args_options_2_params(self, *args, **options): + # FIXME: Check that name_from_ip is valid. This is necessary because + # custom validation rules, including _validate_ipnet, are not + # used when doing a search. Once we have a parameter type for + # IP network objects, this will no longer be necessary, as the + # parameter type will handle the validation itself (see + # <https://fedorahosted.org/freeipa/ticket/2266>). + if 'name_from_ip' in options: + self.obj.params['name_from_ip'](unicode(options['name_from_ip'])) + return super(DNSZoneBase_find, self).args_options_2_params(*args, **options) + + def args_options_2_entry(self, *args, **options): + if 'name_from_ip' in options: + if 'idnsname' not in options: + options['idnsname'] = self.obj.params['idnsname'].get_default(**options) + del options['name_from_ip'] + search_kw = super(DNSZoneBase_find, self).args_options_2_entry(*args, + **options) + name = search_kw.get('idnsname') + if name: + search_kw['idnsname'] = [name, name.relativize(DNSName.root)] + return search_kw + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + # Check if DNS container exists must be here for find methods + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + filter = _create_idn_filter(self, ldap, *args, **options) + return (filter, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + for entry_attrs in entries: + self.obj._make_zonename_absolute(entry_attrs, **options) + return truncated + + +class DNSZoneBase_show(LDAPRetrieve): + has_output_params = LDAPRetrieve.has_output_params + dnszone_output_params + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + if not _check_DN_objectclass(ldap, dn, self.obj.object_class): + self.obj.handle_not_found(*keys) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj._make_zonename_absolute(entry_attrs, **options) + return dn + + +class DNSZoneBase_disable(LDAPQuery): + has_output = output.standard_value + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + try: + entry = ldap.get_entry(dn, ['idnszoneactive', 'objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + entry['idnszoneactive'] = ['FALSE'] + + try: + ldap.update_entry(entry) + except errors.EmptyModlist: + pass + + return dict(result=True, value=pkey_to_value(keys[-1], options)) + + +class DNSZoneBase_enable(LDAPQuery): + has_output = output.standard_value + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + try: + entry = ldap.get_entry(dn, ['idnszoneactive', 'objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + entry['idnszoneactive'] = ['TRUE'] + + try: + ldap.update_entry(entry) + except errors.EmptyModlist: + pass + + return dict(result=True, value=pkey_to_value(keys[-1], options)) + + +class DNSZoneBase_add_permission(LDAPQuery): + has_output = _output_permissions + msg_summary = _('Added system permission "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + dn = self.obj.get_dn(*keys, **options) + + try: + entry_attrs = ldap.get_entry(dn, ['objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + else: + if not _check_entry_objectclass(entry_attrs, self.obj.object_class): + self.obj.handle_not_found(*keys) + + permission_name = self.obj.permission_name(keys[-1]) + + # compatibility with older IPA versions which allows relative zonenames + if keys[-1] != DNSName.root: # special case root zone + permission_name_rel = self.obj.permission_name( + keys[-1].relativize(DNSName.root) + ) + try: + self.api.Object['permission'].get_dn_if_exists( + permission_name_rel) + except errors.NotFound: + pass + else: + # permission exists without absolute domain name + raise errors.DuplicateEntry( + message=_('permission "%(value)s" already exists') % { + 'value': permission_name + } + ) + + permission = self.api.Command['permission_add_noaci'](permission_name, + ipapermissiontype=u'SYSTEM' + )['result'] + + dnszone_ocs = entry_attrs.get('objectclass') + if dnszone_ocs: + for oc in dnszone_ocs: + if oc.lower() == 'ipadnszone': + break + else: + dnszone_ocs.append('ipadnszone') + + entry_attrs['managedby'] = [permission['dn']] + ldap.update_entry(entry_attrs) + + return dict( + result=True, + value=pkey_to_value(permission_name, options), + ) + + +class DNSZoneBase_remove_permission(LDAPQuery): + has_output = _output_permissions + msg_summary = _('Removed system permission "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + dn = self.obj.get_dn(*keys, **options) + try: + entry = ldap.get_entry(dn, ['managedby', 'objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + else: + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + entry['managedby'] = None + + try: + ldap.update_entry(entry) + except errors.EmptyModlist: + # managedBy attribute is clean, lets make sure there is also no + # dangling DNS zone permission + pass + + permission_name = self.obj.permission_name(keys[-1]) + self.obj._remove_permission(keys[-1]) + + return dict( + result=True, + value=pkey_to_value(permission_name, options), + ) + + +@register() +class dnszone(DNSZoneBase): + """ + DNS Zone, container for resource records. + """ + object_name = _('DNS zone') + object_name_plural = _('DNS zones') + object_class = DNSZoneBase.object_class + ['idnsrecord', 'idnszone'] + default_attributes = DNSZoneBase.default_attributes + [ + 'idnssoamname', 'idnssoarname', 'idnssoaserial', 'idnssoarefresh', + 'idnssoaretry', 'idnssoaexpire', 'idnssoaminimum', 'idnsallowquery', + 'idnsallowtransfer', 'idnssecinlinesigning', + ] + _record_attributes + label = _('DNS Zones') + label_singular = _('DNS Zone') + + takes_params = DNSZoneBase.takes_params + ( + DNSNameParam('idnssoamname?', + cli_name='name_server', + label=_('Authoritative nameserver'), + doc=_('Authoritative nameserver domain name'), + default=None, # value will be added in precallback from ldap + ), + DNSNameParam('idnssoarname', + _rname_validator, + cli_name='admin_email', + label=_('Administrator e-mail address'), + doc=_('Administrator e-mail address'), + default=DNSName(u'hostmaster'), + normalizer=normalize_zonemgr, + autofill=True, + ), + Int('idnssoaserial', + cli_name='serial', + label=_('SOA serial'), + doc=_('SOA record serial number'), + minvalue=1, + maxvalue=4294967295, + default_from=_create_zone_serial, + autofill=True, + ), + Int('idnssoarefresh', + cli_name='refresh', + label=_('SOA refresh'), + doc=_('SOA record refresh time'), + minvalue=0, + maxvalue=2147483647, + default=3600, + autofill=True, + ), + Int('idnssoaretry', + cli_name='retry', + label=_('SOA retry'), + doc=_('SOA record retry time'), + minvalue=0, + maxvalue=2147483647, + default=900, + autofill=True, + ), + Int('idnssoaexpire', + cli_name='expire', + label=_('SOA expire'), + doc=_('SOA record expire time'), + default=1209600, + minvalue=0, + maxvalue=2147483647, + autofill=True, + ), + Int('idnssoaminimum', + cli_name='minimum', + label=_('SOA minimum'), + doc=_('How long should negative responses be cached'), + default=3600, + minvalue=0, + maxvalue=2147483647, + autofill=True, + ), + Int('dnsttl?', + cli_name='ttl', + label=_('Time to live'), + doc=_('Time to live for records at zone apex'), + minvalue=0, + maxvalue=2147483647, # see RFC 2181 + ), + StrEnum('dnsclass?', + # Deprecated + cli_name='class', + flags=['no_option'], + values=_record_classes, + ), + Str('idnsupdatepolicy?', + cli_name='update_policy', + label=_('BIND update policy'), + doc=_('BIND update policy'), + default_from=lambda idnsname: default_zone_update_policy(idnsname), + autofill=True + ), + Bool('idnsallowdynupdate?', + cli_name='dynamic_update', + label=_('Dynamic update'), + doc=_('Allow dynamic updates.'), + attribute=True, + default=False, + autofill=True + ), + Str('idnsallowquery?', + _validate_bind_aci, + normalizer=_normalize_bind_aci, + cli_name='allow_query', + label=_('Allow query'), + doc=_('Semicolon separated list of IP addresses or networks which are allowed to issue queries'), + default=u'any;', # anyone can issue queries by default + autofill=True, + ), + Str('idnsallowtransfer?', + _validate_bind_aci, + normalizer=_normalize_bind_aci, + cli_name='allow_transfer', + label=_('Allow transfer'), + doc=_('Semicolon separated list of IP addresses or networks which are allowed to transfer the zone'), + default=u'none;', # no one can issue queries by default + autofill=True, + ), + Bool('idnsallowsyncptr?', + cli_name='allow_sync_ptr', + label=_('Allow PTR sync'), + doc=_('Allow synchronization of forward (A, AAAA) and reverse (PTR) records in the zone'), + ), + Bool('idnssecinlinesigning?', + cli_name='dnssec', + default=False, + label=_('Allow in-line DNSSEC signing'), + doc=_('Allow inline DNSSEC signing of records in the zone'), + ), + Str('nsec3paramrecord?', + _validate_nsec3param_record, + cli_name='nsec3param_rec', + label=_('NSEC3PARAM record'), + doc=_('NSEC3PARAM record for zone in format: hash_algorithm flags iterations salt'), + pattern=r'^\d+ \d+ \d+ (([0-9a-fA-F]{2})+|-)$', + pattern_errmsg=(u'expected format: <0-255> <0-255> <0-65535> ' + 'even-length_hexadecimal_digits_or_hyphen'), + ), + ) + # Permissions will be apllied for forwardzones too + # Store permissions into api.env.basedn, dns container could not exists + managed_permissions = { + 'System: Add DNS Entries': { + 'non_object': True, + 'ipapermright': {'add'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'replaces': [ + '(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:add dns entries";allow (add) groupdn = "ldap:///cn=add dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Read DNS Entries': { + 'non_object': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'ipapermdefaultattr': { + 'objectclass', + 'a6record', 'aaaarecord', 'afsdbrecord', 'aplrecord', 'arecord', + 'certrecord', 'cn', 'cnamerecord', 'dhcidrecord', 'dlvrecord', + 'dnamerecord', 'dnsclass', 'dnsttl', 'dsrecord', + 'hinforecord', 'hiprecord', 'idnsallowdynupdate', + 'idnsallowquery', 'idnsallowsyncptr', 'idnsallowtransfer', + 'idnsforwarders', 'idnsforwardpolicy', 'idnsname', + 'idnssecinlinesigning', 'idnssoaexpire', 'idnssoaminimum', + 'idnssoamname', 'idnssoarefresh', 'idnssoaretry', + 'idnssoarname', 'idnssoaserial', 'idnsupdatepolicy', + 'idnszoneactive', 'ipseckeyrecord','keyrecord', 'kxrecord', + 'locrecord', 'managedby', 'mdrecord', 'minforecord', + 'mxrecord', 'naptrrecord', 'nsecrecord', 'nsec3paramrecord', + 'nsrecord', 'nxtrecord', 'ptrrecord', 'rprecord', 'rrsigrecord', + 'sigrecord', 'spfrecord', 'srvrecord', 'sshfprecord', + 'tlsarecord', 'txtrecord', 'unknownrecord', + }, + 'replaces_system': ['Read DNS Entries'], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Remove DNS Entries': { + 'non_object': True, + 'ipapermright': {'delete'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'replaces': [ + '(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:remove dns entries";allow (delete) groupdn = "ldap:///cn=remove dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Update DNS Entries': { + 'non_object': True, + 'ipapermright': {'write'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'ipapermdefaultattr': { + 'a6record', 'aaaarecord', 'afsdbrecord', 'aplrecord', 'arecord', + 'certrecord', 'cn', 'cnamerecord', 'dhcidrecord', 'dlvrecord', + 'dnamerecord', 'dnsclass', 'dnsttl', 'dsrecord', + 'hinforecord', 'hiprecord', 'idnsallowdynupdate', + 'idnsallowquery', 'idnsallowsyncptr', 'idnsallowtransfer', + 'idnsforwarders', 'idnsforwardpolicy', 'idnsname', + 'idnssecinlinesigning', 'idnssoaexpire', 'idnssoaminimum', + 'idnssoamname', 'idnssoarefresh', 'idnssoaretry', + 'idnssoarname', 'idnssoaserial', 'idnsupdatepolicy', + 'idnszoneactive', 'ipseckeyrecord','keyrecord', 'kxrecord', + 'locrecord', 'managedby', 'mdrecord', 'minforecord', + 'mxrecord', 'naptrrecord', 'nsecrecord', 'nsec3paramrecord', + 'nsrecord', 'nxtrecord', 'ptrrecord', 'rprecord', 'rrsigrecord', + 'sigrecord', 'spfrecord', 'srvrecord', 'sshfprecord', + 'tlsarecord', 'txtrecord', 'unknownrecord', + }, + 'replaces': [ + '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy || idnsallowquery || idnsallowtransfer || idnsallowsyncptr || idnsforwardpolicy || idnsforwarders")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy || idnsallowquery || idnsallowtransfer || idnsallowsyncptr || idnsforwardpolicy || idnsforwarders || managedby")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Read DNSSEC metadata': { + 'non_object': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsSecKey)'], + 'ipapermdefaultattr': { + 'idnsSecAlgorithm', 'idnsSecKeyCreated', 'idnsSecKeyPublish', + 'idnsSecKeyActivate', 'idnsSecKeyInactive', 'idnsSecKeyDelete', + 'idnsSecKeyZone', 'idnsSecKeyRevoke', 'idnsSecKeySep', + 'idnsSecKeyRef', 'cn', 'objectclass', + }, + 'default_privileges': {'DNS Administrators'}, + }, + 'System: Manage DNSSEC metadata': { + 'non_object': True, + 'ipapermright': {'all'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsSecKey)'], + 'ipapermdefaultattr': { + 'idnsSecAlgorithm', 'idnsSecKeyCreated', 'idnsSecKeyPublish', + 'idnsSecKeyActivate', 'idnsSecKeyInactive', 'idnsSecKeyDelete', + 'idnsSecKeyZone', 'idnsSecKeyRevoke', 'idnsSecKeySep', + 'idnsSecKeyRef', 'cn', 'objectclass', + }, + 'default_privileges': {'DNS Servers'}, + }, + 'System: Manage DNSSEC keys': { + 'non_object': True, + 'ipapermright': {'all'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=keys', 'cn=sec', 'cn=dns', api.env.basedn), + 'ipapermdefaultattr': { + 'ipaPublicKey', 'ipaPrivateKey', 'ipaSecretKey', + 'ipaWrappingMech','ipaWrappingKey', + 'ipaSecretKeyRef', 'ipk11Private', 'ipk11Modifiable', 'ipk11Label', + 'ipk11Copyable', 'ipk11Destroyable', 'ipk11Trusted', + 'ipk11CheckValue', 'ipk11StartDate', 'ipk11EndDate', + 'ipk11UniqueId', 'ipk11PublicKeyInfo', 'ipk11Distrusted', + 'ipk11Subject', 'ipk11Id', 'ipk11Local', 'ipk11KeyType', + 'ipk11Derive', 'ipk11KeyGenMechanism', 'ipk11AllowedMechanisms', + 'ipk11Encrypt', 'ipk11Verify', 'ipk11VerifyRecover', 'ipk11Wrap', + 'ipk11WrapTemplate', 'ipk11Sensitive', 'ipk11Decrypt', + 'ipk11Sign', 'ipk11SignRecover', 'ipk11Unwrap', + 'ipk11Extractable', 'ipk11AlwaysSensitive', + 'ipk11NeverExtractable', 'ipk11WrapWithTrusted', + 'ipk11UnwrapTemplate', 'ipk11AlwaysAuthenticate', + 'objectclass', + }, + 'default_privileges': {'DNS Servers'}, + }, + } + + def _rr_zone_postprocess(self, record, **options): + #Decode IDN ACE form to Unicode, raw records are passed directly from LDAP + if options.get('raw', False): + return + _records_idn_postprocess(record, **options) + + def _warning_forwarding(self, result, **options): + if ('idnsforwarders' in result['result']): + messages.add_message(options.get('version', VERSION_WITHOUT_CAPABILITIES), + result, messages.ForwardersWarning()) + + def _warning_name_server_option(self, result, context, **options): + if getattr(context, 'show_warning_nameserver_option', False): + messages.add_message( + options['version'], + result, messages.OptionSemanticChangedWarning( + label=_(u"setting Authoritative nameserver"), + current_behavior=_(u"It is used only for setting the " + u"SOA MNAME attribute."), + hint=_(u"NS record(s) can be edited in zone apex - '@'. ") + ) + ) + + def _warning_fw_zone_is_not_effective(self, result, *keys, **options): + """ + Warning if any operation with zone causes, a child forward zone is + not effective + """ + zone = keys[-1] + affected_fw_zones, truncated = _find_subtree_forward_zones_ldap( + self.api, zone, child_zones_only=True) + if not affected_fw_zones: + return + + for fwzone in affected_fw_zones: + _add_warning_fw_zone_is_not_effective(self.api, result, fwzone, + options['version']) + + def _warning_dnssec_master_is_not_installed(self, result, **options): + dnssec_enabled = result['result'].get("idnssecinlinesigning", False) + if dnssec_enabled and not dnssec_installed(self.api.Backend.ldap2): + messages.add_message( + options['version'], + result, + messages.DNSSECMasterNotInstalled() + ) + + +@register() +class dnszone_add(DNSZoneBase_add): + __doc__ = _('Create new DNS zone (SOA record).') + + takes_options = DNSZoneBase_add.takes_options + ( + Flag('force', + doc=_('Force DNS zone creation even if nameserver is not ' + 'resolvable. (Deprecated)'), + ), + + Flag('skip_nameserver_check', + doc=_('Force DNS zone creation even if nameserver is not ' + 'resolvable.'), + ), + + # Deprecated + # ip-address option is not used anymore, we have to keep it + # due to compability with clients older than 4.1 + Str('ip_address?', + flags=['no_option', ] + ), + ) + + def _warning_deprecated_option(self, result, **options): + if 'ip_address' in options: + messages.add_message( + options['version'], + result, + messages.OptionDeprecatedWarning( + option='ip-address', + additional_info=u"Value will be ignored.") + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + if options.get('force'): + options['skip_nameserver_check'] = True + + dn = super(dnszone_add, self).pre_callback( + ldap, dn, entry_attrs, attrs_list, *keys, **options) + + nameservers = [normalize_zone(x) for x in + self.api.Object.dnsrecord.get_dns_masters()] + server = normalize_zone(api.env.host) + zone = keys[-1] + + if entry_attrs.get('idnssoamname'): + if zone.is_reverse() and not entry_attrs['idnssoamname'].is_absolute(): + raise errors.ValidationError( + name='name-server', + error=_("Nameserver for reverse zone cannot be a relative DNS name")) + + # verify if user specified server is resolvable + if not options['skip_nameserver_check']: + check_ns_rec_resolvable(keys[0], entry_attrs['idnssoamname'], + self.log) + # show warning about --name-server option + context.show_warning_nameserver_option = True + else: + # user didn't specify SOA mname + if server in nameservers: + # current ipa server is authoritative nameserver in SOA record + entry_attrs['idnssoamname'] = [server] + else: + # a first DNS capable server is authoritative nameserver in SOA record + entry_attrs['idnssoamname'] = [nameservers[0]] + + # all ipa DNS servers should be in NS zone record (as absolute domain name) + entry_attrs['nsrecord'] = nameservers + + return dn + + def execute(self, *keys, **options): + result = super(dnszone_add, self).execute(*keys, **options) + self._warning_deprecated_option(result, **options) + self.obj._warning_forwarding(result, **options) + self.obj._warning_name_server_option(result, context, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + self.obj._warning_dnssec_master_is_not_installed(result, **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + # Add entry to realmdomains + # except for our own domain, forward zones, reverse zones and root zone + zone = keys[0] + + if (zone != DNSName(api.env.domain).make_absolute() and + not options.get('idnsforwarders') and + not zone.is_reverse() and + zone != DNSName.root): + try: + self.api.Command['realmdomains_mod'](add_domain=unicode(zone), + force=True) + except (errors.EmptyModlist, errors.ValidationError): + pass + + self.obj._rr_zone_postprocess(entry_attrs, **options) + return dn + + + +@register() +class dnszone_del(DNSZoneBase_del): + __doc__ = _('Delete DNS zone (SOA record).') + + msg_summary = _('Deleted DNS zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnszone_del, self).execute(*keys, **options) + nkeys = keys[-1] # we can delete more zones + for key in nkeys: + self.obj._warning_fw_zone_is_not_effective(result, key, **options) + return result + + def post_callback(self, ldap, dn, *keys, **options): + super(dnszone_del, self).post_callback(ldap, dn, *keys, **options) + + # Delete entry from realmdomains + # except for our own domain, reverse zone, and root zone + zone = keys[0].make_absolute() + + if (zone != DNSName(api.env.domain).make_absolute() and + not zone.is_reverse() and zone != DNSName.root + ): + try: + self.api.Command['realmdomains_mod']( + del_domain=unicode(zone), force=True) + except (errors.AttrValueNotFound, errors.ValidationError): + pass + + return True + + + +@register() +class dnszone_mod(DNSZoneBase_mod): + __doc__ = _('Modify DNS zone (SOA record).') + + takes_options = DNSZoneBase_mod.takes_options + ( + Flag('force', + label=_('Force'), + doc=_('Force nameserver change even if nameserver not in DNS'), + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + if not _check_DN_objectclass(ldap, dn, self.obj.object_class): + self.obj.handle_not_found(*keys) + if 'idnssoamname' in entry_attrs: + nameserver = entry_attrs['idnssoamname'] + if nameserver: + if not nameserver.is_empty() and not options['force']: + check_ns_rec_resolvable(keys[0], nameserver, self.log) + context.show_warning_nameserver_option = True + else: + # empty value, this option is required by ldap + raise errors.ValidationError( + name='name_server', + error=_(u"is required")) + + return dn + + def execute(self, *keys, **options): + result = super(dnszone_mod, self).execute(*keys, **options) + self.obj._warning_forwarding(result, **options) + self.obj._warning_name_server_option(result, context, **options) + self.obj._warning_dnssec_master_is_not_installed(result, **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + dn = super(dnszone_mod, self).post_callback(ldap, dn, entry_attrs, + *keys, **options) + self.obj._rr_zone_postprocess(entry_attrs, **options) + return dn + + +@register() +class dnszone_find(DNSZoneBase_find): + __doc__ = _('Search for DNS zones (SOA records).') + + takes_options = DNSZoneBase_find.takes_options + ( + Flag('forward_only', + label=_('Forward zones only'), + cli_name='forward_only', + doc=_('Search for forward zones only'), + ), + ) + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + + filter, base, dn = super(dnszone_find, self).pre_callback(ldap, filter, + attrs_list, base_dn, scope, *args, **options) + + if options.get('forward_only', False): + search_kw = {} + search_kw['idnsname'] = [revzone.ToASCII() for revzone in + REVERSE_DNS_ZONES.keys()] + rev_zone_filter = ldap.make_filter(search_kw, + rules=ldap.MATCH_NONE, + exact=False, + trailing_wildcard=False) + filter = ldap.combine_filters((rev_zone_filter, filter), + rules=ldap.MATCH_ALL) + + return (filter, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + truncated = super(dnszone_find, self).post_callback(ldap, entries, + truncated, *args, + **options) + for entry_attrs in entries: + self.obj._rr_zone_postprocess(entry_attrs, **options) + return truncated + + + +@register() +class dnszone_show(DNSZoneBase_show): + __doc__ = _('Display information about a DNS zone (SOA record).') + + def execute(self, *keys, **options): + result = super(dnszone_show, self).execute(*keys, **options) + self.obj._warning_forwarding(result, **options) + self.obj._warning_dnssec_master_is_not_installed(result, **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + dn = super(dnszone_show, self).post_callback(ldap, dn, entry_attrs, + *keys, **options) + self.obj._rr_zone_postprocess(entry_attrs, **options) + return dn + + + +@register() +class dnszone_disable(DNSZoneBase_disable): + __doc__ = _('Disable DNS Zone.') + msg_summary = _('Disabled DNS zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnszone_disable, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + return result + + +@register() +class dnszone_enable(DNSZoneBase_enable): + __doc__ = _('Enable DNS Zone.') + msg_summary = _('Enabled DNS zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnszone_enable, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + return result + + +@register() +class dnszone_add_permission(DNSZoneBase_add_permission): + __doc__ = _('Add a permission for per-zone access delegation.') + + +@register() +class dnszone_remove_permission(DNSZoneBase_remove_permission): + __doc__ = _('Remove a permission for per-zone access delegation.') + + +@register() +class dnsrecord(LDAPObject): + """ + DNS record. + """ + parent_object = 'dnszone' + container_dn = api.env.container_dns + object_name = _('DNS resource record') + object_name_plural = _('DNS resource records') + object_class = ['top', 'idnsrecord'] + permission_filter_objectclasses = ['idnsrecord'] + default_attributes = ['idnsname'] + _record_attributes + rdn_is_primary_key = True + + label = _('DNS Resource Records') + label_singular = _('DNS Resource Record') + + takes_params = ( + DNSNameParam('idnsname', + cli_name='name', + label=_('Record name'), + doc=_('Record name'), + primary_key=True, + ), + Int('dnsttl?', + cli_name='ttl', + label=_('Time to live'), + doc=_('Time to live'), + ), + StrEnum('dnsclass?', + # Deprecated + cli_name='class', + flags=['no_option'], + values=_record_classes, + ), + ) + _dns_record_options + + structured_flag = Flag('structured', + label=_('Structured'), + doc=_('Parse all raw DNS records and return them in a structured way'), + ) + + def _dsrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + dsrecords = entry_attrs.get('dsrecord') + if dsrecords and self.is_pkey_zone_record(*keys): + raise errors.ValidationError( + name='dsrecord', + error=unicode(_('DS record must not be in zone apex (RFC 4035 section 2.4)'))) + + def _nsrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + nsrecords = entry_attrs.get('nsrecord') + if options.get('force', False) or nsrecords is None: + return + for nsrecord in nsrecords: + check_ns_rec_resolvable(keys[0], DNSName(nsrecord), self.log) + + def _idnsname_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if keys[-1].is_absolute(): + if keys[-1].is_subdomain(keys[-2]): + entry_attrs['idnsname'] = [keys[-1].relativize(keys[-2])] + elif not self.is_pkey_zone_record(*keys): + raise errors.ValidationError(name='idnsname', + error=unicode(_('out-of-zone data: record name must ' + 'be a subdomain of the zone or a ' + 'relative name'))) + # dissallowed wildcard (RFC 4592 section 4) + no_wildcard_rtypes = ['DNAME', 'DS', 'NS'] + if (keys[-1].is_wild() and + any(entry_attrs.get(record_name_format % r.lower()) + for r in no_wildcard_rtypes) + ): + raise errors.ValidationError( + name='idnsname', + error=(_('owner of %(types)s records ' + 'should not be a wildcard domain name (RFC 4592 section 4)') % + {'types': ', '.join(no_wildcard_rtypes)} + ) + ) + + def _ptrrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + ptrrecords = entry_attrs.get('ptrrecord') + if ptrrecords is None: + return + + zone = keys[-2] + if self.is_pkey_zone_record(*keys): + addr = _dns_zone_record + else: + addr = keys[-1] + + zone_len = 0 + for valid_zone in REVERSE_DNS_ZONES: + if zone.is_subdomain(valid_zone): + zone = zone.relativize(valid_zone) + zone_name = valid_zone + zone_len = REVERSE_DNS_ZONES[valid_zone] + + if not zone_len: + allowed_zones = ', '.join([unicode(revzone) for revzone in + REVERSE_DNS_ZONES.keys()]) + raise errors.ValidationError(name='ptrrecord', + error=unicode(_('Reverse zone for PTR record should be a sub-zone of one the following fully qualified domains: %s') % allowed_zones)) + + addr_len = len(addr.labels) + + # Classless zones (0/25.0.0.10.in-addr.arpa.) -> skip check + # zone has to be checked without reverse domain suffix (in-addr.arpa.) + for sign in ('/', '-'): + for name in (zone, addr): + for label in name.labels: + if sign in label: + return + + ip_addr_comp_count = addr_len + len(zone.labels) + if ip_addr_comp_count != zone_len: + raise errors.ValidationError(name='ptrrecord', + error=unicode(_('Reverse zone %(name)s requires exactly ' + '%(count)d IP address components, ' + '%(user_count)d given') + % dict(name=zone_name, + count=zone_len, + user_count=ip_addr_comp_count))) + + def run_precallback_validators(self, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + ldap = self.api.Backend.ldap2 + + for rtype in entry_attrs.keys(): + rtype_cb = getattr(self, '_%s_pre_callback' % rtype, None) + if rtype_cb: + rtype_cb(ldap, dn, entry_attrs, *keys, **options) + + def is_pkey_zone_record(self, *keys): + assert isinstance(keys[-1], DNSName) + assert isinstance(keys[-2], DNSName) + idnsname = keys[-1] + zonename = keys[-2] + if idnsname.is_empty() or idnsname == zonename: + return True + return False + + def check_zone(self, zone, **options): + """ + Check if zone exists and if is master zone + """ + parent_object = self.api.Object[self.parent_object] + dn = parent_object.get_dn(zone, **options) + ldap = self.api.Backend.ldap2 + try: + entry = ldap.get_entry(dn, ['objectclass']) + except errors.NotFound: + parent_object.handle_not_found(zone) + else: + # only master zones can contain records + if 'idnszone' not in [x.lower() for x in entry.get('objectclass', [])]: + raise errors.ValidationError( + name='dnszoneidnsname', + error=_(u'only master zones can contain records') + ) + return dn + + + def get_dn(self, *keys, **options): + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + + dn = self.check_zone(keys[-2], **options) + + if self.is_pkey_zone_record(*keys): + return dn + + #Make RR name relative if possible + relative_name = keys[-1].relativize(keys[-2]).ToASCII() + keys = keys[:-1] + (relative_name,) + return super(dnsrecord, self).get_dn(*keys, **options) + + def attr_to_cli(self, attr): + cliname = get_record_rrtype(attr) + if not cliname: + cliname = attr + return cliname + + def get_dns_masters(self): + ldap = self.api.Backend.ldap2 + base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), self.api.env.basedn) + ldap_filter = '(&(objectClass=ipaConfigObject)(cn=DNS))' + dns_masters = [] + + try: + entries = ldap.find_entries(filter=ldap_filter, base_dn=base_dn)[0] + + for entry in entries: + try: + master = entry.dn[1]['cn'] + dns_masters.append(master) + except (IndexError, KeyError): + pass + except errors.NotFound: + return [] + + return dns_masters + + def get_record_entry_attrs(self, entry_attrs): + entry_attrs = entry_attrs.copy() + for attr in entry_attrs.keys(): + if attr not in self.params or self.params[attr].primary_key: + del entry_attrs[attr] + return entry_attrs + + def postprocess_record(self, record, **options): + if options.get('structured', False): + for attr in record.keys(): + # attributes in LDAPEntry may not be normalized + attr = attr.lower() + try: + param = self.params[attr] + except KeyError: + continue + + if not isinstance(param, DNSRecord): + continue + parts_params = param.get_parts() + + for dnsvalue in record[attr]: + dnsentry = { + u'dnstype' : unicode(param.rrtype), + u'dnsdata' : dnsvalue + } + values = param._get_part_values(dnsvalue) + if values is None: + continue + for val_id, val in enumerate(values): + if val is not None: + #decode IDN + if isinstance(parts_params[val_id], DNSNameParam): + dnsentry[parts_params[val_id].name] = \ + _dns_name_to_string(val, + options.get('raw', False)) + else: + dnsentry[parts_params[val_id].name] = val + record.setdefault('dnsrecords', []).append(dnsentry) + del record[attr] + + elif not options.get('raw', False): + #Decode IDN ACE form to Unicode, raw records are passed directly from LDAP + _records_idn_postprocess(record, **options) + + def updated_rrattrs(self, old_entry, entry_attrs): + """Returns updated RR attributes + """ + rrattrs = {} + if old_entry is not None: + old_rrattrs = dict((key, value) for key, value in old_entry.items() + if key in self.params and + isinstance(self.params[key], DNSRecord)) + rrattrs.update(old_rrattrs) + new_rrattrs = dict((key, value) for key, value in entry_attrs.items() + if key in self.params and + isinstance(self.params[key], DNSRecord)) + rrattrs.update(new_rrattrs) + return rrattrs + + def check_record_type_collisions(self, keys, rrattrs): + # Test that only allowed combination of record types was created + + # CNAME record validation + cnames = rrattrs.get('cnamerecord') + if cnames is not None: + if len(cnames) > 1: + raise errors.ValidationError(name='cnamerecord', + error=_('only one CNAME record is allowed per name ' + '(RFC 2136, section 1.1.5)')) + if any(rrvalue is not None + and rrattr != 'cnamerecord' + for rrattr, rrvalue in rrattrs.items()): + raise errors.ValidationError(name='cnamerecord', + error=_('CNAME record is not allowed to coexist ' + 'with any other record (RFC 1034, section 3.6.2)')) + + # DNAME record validation + dnames = rrattrs.get('dnamerecord') + if dnames is not None: + if len(dnames) > 1: + raise errors.ValidationError(name='dnamerecord', + error=_('only one DNAME record is allowed per name ' + '(RFC 6672, section 2.4)')) + # DNAME must not coexist with CNAME, but this is already checked earlier + + # NS record validation + # NS record can coexist only with A, AAAA, DS, and other NS records (except zone apex) + # RFC 2181 section 6.1, + allowed_records = ['AAAA', 'A', 'DS', 'NS'] + nsrecords = rrattrs.get('nsrecord') + if nsrecords and not self.is_pkey_zone_record(*keys): + for r_type in _record_types: + if (r_type not in allowed_records + and rrattrs.get(record_name_format % r_type.lower()) + ): + raise errors.ValidationError( + name='nsrecord', + error=_('NS record is not allowed to coexist with an ' + '%(type)s record except when located in a ' + 'zone root record (RFC 2181, section 6.1)') % + {'type': r_type}) + + def check_record_type_dependencies(self, keys, rrattrs): + # Test that all record type dependencies are satisfied + + # DS record validation + # DS record requires to coexists with NS record + dsrecords = rrattrs.get('dsrecord') + nsrecords = rrattrs.get('nsrecord') + # DS record cannot be in zone apex, checked in pre-callback validators + if dsrecords and not nsrecords: + raise errors.ValidationError( + name='dsrecord', + error=_('DS record requires to coexist with an ' + 'NS record (RFC 4592 section 4.6, RFC 4035 section 2.4)')) + + def _entry2rrsets(self, entry_attrs, dns_name, dns_domain): + '''Convert entry_attrs to a dictionary {rdtype: rrset}. + + :returns: + None if entry_attrs is None + {rdtype: None} if RRset of given type is empty + {rdtype: RRset} if RRset of given type is non-empty + ''' + ldap_rrsets = {} + + if not entry_attrs: + # all records were deleted => name should not exist in DNS + return None + + for attr, value in entry_attrs.items(): + rrtype = get_record_rrtype(attr) + if not rrtype: + continue + + rdtype = dns.rdatatype.from_text(rrtype) + if not value: + ldap_rrsets[rdtype] = None # RRset is empty + continue + + try: + # TTL here can be arbitrary value because it is ignored + # during comparison + ldap_rrset = dns.rrset.from_text( + dns_name, 86400, dns.rdataclass.IN, rdtype, + *[str(v) for v in value]) + + # make sure that all names are absolute so RRset + # comparison will work + for ldap_rr in ldap_rrset: + ldap_rr.choose_relativity(origin=dns_domain, + relativize=False) + ldap_rrsets[rdtype] = ldap_rrset + + except dns.exception.SyntaxError as e: + self.log.error('DNS syntax error: %s %s %s: %s', dns_name, + dns.rdatatype.to_text(rdtype), value, e) + raise + + return ldap_rrsets + + def wait_for_modified_attr(self, ldap_rrset, rdtype, dns_name): + '''Wait until DNS resolver returns up-to-date answer for given RRset + or until the maximum number of attempts is reached. + Number of attempts is controlled by self.api.env['wait_for_dns']. + + :param ldap_rrset: + None if given rdtype should not exist or + dns.rrset.RRset to match against data in DNS. + :param dns_name: FQDN to query + :type dns_name: dns.name.Name + :return: None if data in DNS and LDAP match + :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match + :raises dns.exception.DNSException: if DNS resolution failed + ''' + resolver = dns.resolver.Resolver() + resolver.set_flags(0) # disable recursion (for NS RR checks) + max_attempts = int(self.api.env['wait_for_dns']) + warn_attempts = max_attempts // 2 + period = 1 # second + attempt = 0 + log_fn = self.log.debug + log_fn('querying DNS server: expecting answer {%s}', ldap_rrset) + wait_template = 'waiting for DNS answer {%s}: got {%s} (attempt %s); '\ + 'waiting %s seconds before next try' + + while attempt < max_attempts: + if attempt >= warn_attempts: + log_fn = self.log.warning + attempt += 1 + try: + dns_answer = resolver.query(dns_name, rdtype, + dns.rdataclass.IN, + raise_on_no_answer=False) + dns_rrset = None + if rdtype == _NS: + # NS records can be in Authority section (sometimes) + dns_rrset = dns_answer.response.get_rrset( + dns_answer.response.authority, dns_name, _IN, rdtype) + + if not dns_rrset: + # Look for NS and other data in Answer section + dns_rrset = dns_answer.rrset + + if dns_rrset == ldap_rrset: + log_fn('DNS answer matches expectations (attempt %s)', + attempt) + return + + log_msg = wait_template % (ldap_rrset, dns_answer.response, + attempt, period) + + except (dns.resolver.NXDOMAIN, + dns.resolver.YXDOMAIN, + dns.resolver.NoNameservers, + dns.resolver.Timeout) as e: + if attempt >= max_attempts: + raise + else: + log_msg = wait_template % (ldap_rrset, type(e), attempt, + period) + + log_fn(log_msg) + time.sleep(period) + + # Maximum number of attempts was reached + else: + raise errors.DNSDataMismatch(expected=ldap_rrset, got=dns_rrset) + + def wait_for_modified_attrs(self, entry_attrs, dns_name, dns_domain): + '''Wait until DNS resolver returns up-to-date answer for given entry + or until the maximum number of attempts is reached. + + :param entry_attrs: + None if the entry was deleted from LDAP or + LDAPEntry instance containing at least all modified attributes. + :param dns_name: FQDN + :type dns_name: dns.name.Name + :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match + ''' + + # represent data in LDAP as dictionary rdtype => rrset + ldap_rrsets = self._entry2rrsets(entry_attrs, dns_name, dns_domain) + nxdomain = ldap_rrsets is None + if nxdomain: + # name should not exist => ask for A record and check result + ldap_rrsets = {dns.rdatatype.from_text('A'): None} + + for rdtype, ldap_rrset in ldap_rrsets.items(): + try: + self.wait_for_modified_attr(ldap_rrset, rdtype, dns_name) + + except dns.resolver.NXDOMAIN as e: + if nxdomain: + continue + else: + e = errors.DNSDataMismatch(expected=ldap_rrset, + got="NXDOMAIN") + self.log.error(e) + raise e + + except dns.resolver.NoNameservers as e: + # Do not raise exception if we have got SERVFAILs. + # Maybe the user has created an invalid zone intentionally. + self.log.warning('waiting for DNS answer {%s}: got {%s}; ' + 'ignoring', ldap_rrset, type(e)) + continue + + except dns.exception.DNSException as e: + err_desc = str(type(e)) + err_str = str(e) + if err_str: + err_desc += ": %s" % err_str + e = errors.DNSDataMismatch(expected=ldap_rrset, got=err_desc) + self.log.error(e) + raise e + + def wait_for_modified_entries(self, entries): + '''Call wait_for_modified_attrs for all entries in given dict. + + :param entries: + Dict {(dns_domain, dns_name): entry_for_wait_for_modified_attrs} + ''' + for entry_name, entry in entries.items(): + dns_domain = entry_name[0] + dns_name = entry_name[1].derelativize(dns_domain) + self.wait_for_modified_attrs(entry, dns_name, dns_domain) + + def warning_if_ns_change_cause_fwzone_ineffective(self, result, *keys, + **options): + """Detect if NS record change can make forward zones ineffective due + missing delegation. Run after parent's execute method. + """ + record_name_absolute = keys[-1] + zone = keys[-2] + + if not record_name_absolute.is_absolute(): + record_name_absolute = record_name_absolute.derelativize(zone) + + affected_fw_zones, truncated = _find_subtree_forward_zones_ldap( + self.api, record_name_absolute) + if not affected_fw_zones: + return + + for fwzone in affected_fw_zones: + _add_warning_fw_zone_is_not_effective(self.api, result, fwzone, + options['version']) + + def warning_suspicious_relative_name(self, result, *keys, **options): + """Detect if zone name is suffix of relative record name and warn. + + Zone name: test.zone. + Relative name: record.test.zone + """ + record_name = keys[-1] + zone = keys[-2] + if not record_name.is_absolute() and record_name.is_subdomain( + zone.relativize(DNSName.root)): + messages.add_message( + options['version'], + result, + messages.DNSSuspiciousRelativeName(record=record_name, + zone=zone, + fqdn=record_name + zone) + ) + + +@register() +class dnsrecord_split_parts(Command): + NO_CLI = True + + takes_args = ( + Str('name'), + Str('value'), + ) + + def execute(self, name, value, *args, **options): + result = self.api.Object.dnsrecord.params[name]._get_part_values(value) + return dict(result=result) + + +@register() +class dnsrecord_add(LDAPCreate): + __doc__ = _('Add new DNS resource record.') + + no_option_msg = 'No options to add a specific record provided.\n' \ + "Command help may be consulted for all supported record types." + takes_options = LDAPCreate.takes_options + ( + Flag('force', + label=_('Force'), + flags=['no_option', 'no_output'], + doc=_('force NS record creation even if its hostname is not in DNS'), + ), + dnsrecord.structured_flag, + ) + + def args_options_2_entry(self, *keys, **options): + has_cli_options(self, options, self.no_option_msg) + return super(dnsrecord_add, self).args_options_2_entry(*keys, **options) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + precallback_attrs = [] + processed_attrs = [] + for option in options: + try: + param = self.params[option] + except KeyError: + continue + + rrparam = get_rrparam_from_part(self, option) + if rrparam is None: + continue + + if 'dnsrecord_part' in param.flags: + if rrparam.name in processed_attrs: + # this record was already entered + continue + if rrparam.name in entry_attrs: + # this record is entered both via parts and raw records + raise errors.ValidationError(name=param.cli_name or param.name, + error=_('Raw value of a DNS record was already set by "%(name)s" option') \ + % dict(name=rrparam.cli_name or rrparam.name)) + + parts = rrparam.get_parts_from_kw(options) + dnsvalue = [rrparam._convert_scalar(parts)] + entry_attrs[rrparam.name] = dnsvalue + processed_attrs.append(rrparam.name) + continue + + if 'dnsrecord_extra' in param.flags: + # do not run precallback for unset flags + if isinstance(param, Flag) and not options[option]: + continue + # extra option is passed, run per-type pre_callback for given RR type + precallback_attrs.append(rrparam.name) + + # Run pre_callback validators + self.obj.run_precallback_validators(dn, entry_attrs, *keys, **options) + + # run precallback also for all new RR type attributes in entry_attrs + for attr in entry_attrs.keys(): + try: + param = self.params[attr] + except KeyError: + continue + + if not isinstance(param, DNSRecord): + continue + precallback_attrs.append(attr) + + precallback_attrs = list(set(precallback_attrs)) + + for attr in precallback_attrs: + # run per-type + try: + param = self.params[attr] + except KeyError: + continue + param.dnsrecord_add_pre_callback(ldap, dn, entry_attrs, attrs_list, *keys, **options) + + # Store all new attrs so that DNSRecord post callback is called for + # new attributes only and not for all attributes in the LDAP entry + setattr(context, 'dnsrecord_precallback_attrs', precallback_attrs) + + # We always want to retrieve all DNS record attributes to test for + # record type collisions (#2601) + try: + old_entry = ldap.get_entry(dn, _record_attributes) + except errors.NotFound: + old_entry = None + else: + for attr in entry_attrs.keys(): + if attr not in _record_attributes: + continue + if entry_attrs[attr] is None: + entry_attrs[attr] = [] + if not isinstance(entry_attrs[attr], (tuple, list)): + vals = [entry_attrs[attr]] + else: + vals = list(entry_attrs[attr]) + entry_attrs[attr] = list(set(old_entry.get(attr, []) + vals)) + + rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs) + self.obj.check_record_type_dependencies(keys, rrattrs) + self.obj.check_record_type_collisions(keys, rrattrs) + context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods', + {}) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy() + + return dn + + def execute(self, *keys, **options): + result = super(dnsrecord_add, self).execute(*keys, **options) + self.obj.warning_suspicious_relative_name(result, *keys, **options) + return result + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + if call_func.__name__ == 'add_entry': + if isinstance(exc, errors.DuplicateEntry): + # A new record is being added to existing LDAP DNS object + # Update can be safely run as old record values has been + # already merged in pre_callback + ldap = self.obj.backend + entry_attrs = self.obj.get_record_entry_attrs(call_args[0]) + update = ldap.get_entry(entry_attrs.dn, list(entry_attrs)) + update.update(entry_attrs) + ldap.update_entry(update, **call_kwargs) + return + raise exc + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + for attr in getattr(context, 'dnsrecord_precallback_attrs', []): + param = self.params[attr] + param.dnsrecord_add_post_callback(ldap, dn, entry_attrs, *keys, **options) + + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + + self.obj.postprocess_record(entry_attrs, **options) + + if self.api.env['wait_for_dns']: + self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods) + return dn + + + +@register() +class dnsrecord_mod(LDAPUpdate): + __doc__ = _('Modify a DNS resource record.') + + no_option_msg = 'No options to modify a specific record provided.' + + takes_options = LDAPUpdate.takes_options + ( + dnsrecord.structured_flag, + ) + + def args_options_2_entry(self, *keys, **options): + has_cli_options(self, options, self.no_option_msg, True) + return super(dnsrecord_mod, self).args_options_2_entry(*keys, **options) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + if options.get('rename') and self.obj.is_pkey_zone_record(*keys): + # zone rename is not allowed + raise errors.ValidationError(name='rename', + error=_('DNS zone root record cannot be renamed')) + + # check if any attr should be updated using structured instead of replaced + # format is recordname : (old_value, new_parts) + updated_attrs = {} + for param in iterate_rrparams_by_parts(self, options, skip_extra=True): + parts = param.get_parts_from_kw(options, raise_on_none=False) + + if parts is None: + # old-style modification + continue + + old_value = entry_attrs.get(param.name) + if not old_value: + raise errors.RequirementError(name=param.name) + if isinstance(old_value, (tuple, list)): + if len(old_value) > 1: + raise errors.ValidationError(name=param.name, + error=_('DNS records can be only updated one at a time')) + old_value = old_value[0] + + updated_attrs[param.name] = (old_value, parts) + + # Run pre_callback validators + self.obj.run_precallback_validators(dn, entry_attrs, *keys, **options) + + # current entry is needed in case of per-dns-record-part updates and + # for record type collision check + try: + old_entry = ldap.get_entry(dn, _record_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if updated_attrs: + for attr in updated_attrs: + param = self.params[attr] + old_dnsvalue, new_parts = updated_attrs[attr] + + if old_dnsvalue not in old_entry.get(attr, []): + attr_name = unicode(param.label or param.name) + raise errors.AttrValueNotFound(attr=attr_name, + value=old_dnsvalue) + old_entry[attr].remove(old_dnsvalue) + + old_parts = param._get_part_values(old_dnsvalue) + modified_parts = tuple(part if part is not None else old_parts[part_id] \ + for part_id,part in enumerate(new_parts)) + + new_dnsvalue = [param._convert_scalar(modified_parts)] + entry_attrs[attr] = list(set(old_entry[attr] + new_dnsvalue)) + + rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs) + self.obj.check_record_type_dependencies(keys, rrattrs) + self.obj.check_record_type_collisions(keys, rrattrs) + + context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods', + {}) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy() + return dn + + def execute(self, *keys, **options): + result = super(dnsrecord_mod, self).execute(*keys, **options) + + # remove if empty + if not self.obj.is_pkey_zone_record(*keys): + rename = options.get('rename') + if rename is not None: + keys = keys[:-1] + (rename,) + dn = self.obj.get_dn(*keys, **options) + ldap = self.obj.backend + old_entry = ldap.get_entry(dn, _record_attributes) + + del_all = True + for attr in old_entry.keys(): + if old_entry[attr]: + del_all = False + break + + if del_all: + result = self.obj.methods.delentry(*keys, + version=options['version']) + + # we need to modify delete result to match mod output type + # only one value is expected, not a list + if client_has_capability(options['version'], 'primary_key_types'): + assert len(result['value']) == 1 + result['value'] = result['value'][0] + + # indicate that entry was deleted + context.dnsrecord_entry_mods[(keys[0], keys[1])] = None + + if self.api.env['wait_for_dns']: + self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods) + if 'nsrecord' in options: + self.obj.warning_if_ns_change_cause_fwzone_ineffective(result, + *keys, + **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + + self.obj.postprocess_record(entry_attrs, **options) + return dn + + +@register() +class dnsrecord_delentry(LDAPDelete): + """ + Delete DNS record entry. + """ + msg_summary = _('Deleted record "%(value)s"') + NO_CLI = True + + + +@register() +class dnsrecord_del(LDAPUpdate): + __doc__ = _('Delete DNS resource record.') + + has_output = output.standard_multi_delete + + no_option_msg = _('Neither --del-all nor options to delete a specific record provided.\n'\ + "Command help may be consulted for all supported record types.") + + takes_options = ( + Flag('del_all', + default=False, + label=_('Delete all associated records'), + ), + dnsrecord.structured_flag, + ) + + def get_options(self): + for option in super(dnsrecord_del, self).get_options(): + if any(flag in option.flags for flag in \ + ('dnsrecord_part', 'dnsrecord_extra',)): + continue + elif option.name in ('rename', ): + # options only valid for dnsrecord-mod + continue + elif isinstance(option, DNSRecord): + yield option.clone(option_group=None) + continue + yield option + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + try: + old_entry = ldap.get_entry(dn, _record_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + for attr in entry_attrs.keys(): + if attr not in _record_attributes: + continue + if not isinstance(entry_attrs[attr], (tuple, list)): + vals = [entry_attrs[attr]] + else: + vals = entry_attrs[attr] + + for val in vals: + try: + old_entry[attr].remove(val) + except (KeyError, ValueError): + try: + param = self.params[attr] + attr_name = unicode(param.label or param.name) + except Exception: + attr_name = attr + raise errors.AttrValueNotFound(attr=attr_name, value=val) + entry_attrs[attr] = list(set(old_entry[attr])) + + rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs) + self.obj.check_record_type_dependencies(keys, rrattrs) + + del_all = False + if not self.obj.is_pkey_zone_record(*keys): + record_found = False + for attr in old_entry.keys(): + if old_entry[attr]: + record_found = True + break + del_all = not record_found + + # set del_all flag in context + # when the flag is enabled, the entire DNS record object is deleted + # in a post callback + context.del_all = del_all + context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods', + {}) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy() + + return dn + + def execute(self, *keys, **options): + if options.get('del_all', False): + if self.obj.is_pkey_zone_record(*keys): + raise errors.ValidationError( + name='del_all', + error=_('Zone record \'%s\' cannot be deleted') \ + % _dns_zone_record + ) + result = self.obj.methods.delentry(*keys, + version=options['version']) + if self.api.env['wait_for_dns']: + entries = {(keys[0], keys[1]): None} + self.obj.wait_for_modified_entries(entries) + else: + result = super(dnsrecord_del, self).execute(*keys, **options) + result['value'] = pkey_to_value([keys[-1]], options) + + if getattr(context, 'del_all', False) and not \ + self.obj.is_pkey_zone_record(*keys): + result = self.obj.methods.delentry(*keys, + version=options['version']) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = None + + if self.api.env['wait_for_dns']: + self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods) + + if 'nsrecord' in options or options.get('del_all', False): + self.obj.warning_if_ns_change_cause_fwzone_ineffective(result, + *keys, + **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + self.obj.postprocess_record(entry_attrs, **options) + return dn + + def args_options_2_entry(self, *keys, **options): + has_cli_options(self, options, self.no_option_msg) + return super(dnsrecord_del, self).args_options_2_entry(*keys, **options) + + +@register() +class dnsrecord_show(LDAPRetrieve): + __doc__ = _('Display DNS resource.') + + takes_options = LDAPRetrieve.takes_options + ( + dnsrecord.structured_flag, + ) + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + self.obj.postprocess_record(entry_attrs, **options) + return dn + + + +@register() +class dnsrecord_find(LDAPSearch): + __doc__ = _('Search for DNS resources.') + + takes_options = LDAPSearch.takes_options + ( + dnsrecord.structured_flag, + ) + + def get_options(self): + for option in super(dnsrecord_find, self).get_options(): + if any(flag in option.flags for flag in \ + ('dnsrecord_part', 'dnsrecord_extra',)): + continue + elif isinstance(option, DNSRecord): + yield option.clone(option_group=None) + continue + yield option + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, + dnszoneidnsname, *args, **options): + assert isinstance(base_dn, DN) + + # validate if zone is master zone + self.obj.check_zone(dnszoneidnsname, **options) + + filter = _create_idn_filter(self, ldap, *args, **options) + return (filter, base_dn, ldap.SCOPE_SUBTREE) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if entries: + zone_obj = self.api.Object[self.obj.parent_object] + zone_dn = zone_obj.get_dn(args[0]) + if entries[0].dn == zone_dn: + entries[0][zone_obj.primary_key.name] = [_dns_zone_record] + for entry in entries: + self.obj.postprocess_record(entry, **options) + + return truncated + + +@register() +class dns_resolve(Command): + __doc__ = _('Resolve a host name in DNS. (Deprecated)') + + NO_CLI = True + + has_output = output.standard_value + msg_summary = _('Found \'%(value)s\'') + + takes_args = ( + Str('hostname', + label=_('Hostname (FQDN)'), + ), + ) + + def execute(self, *args, **options): + query=args[0] + + try: + verify_host_resolvable(query) + except errors.DNSNotARecordError: + raise errors.NotFound( + reason=_('Host \'%(host)s\' not found') % {'host': query} + ) + result = dict(result=True, value=query) + messages.add_message( + options['version'], result, + messages.CommandDeprecatedWarning( + command='dns-resolve', + additional_info='The command may return an unexpected result, ' + 'the resolution of the DNS domain is done on ' + 'a randomly chosen IPA server.' + ) + ) + return result + + +@register() +class dns_is_enabled(Command): + """ + Checks if any of the servers has the DNS service enabled. + """ + NO_CLI = True + has_output = output.standard_value + + base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn) + filter = '(&(objectClass=ipaConfigObject)(cn=DNS))' + + def execute(self, *args, **options): + ldap = self.api.Backend.ldap2 + dns_enabled = False + + try: + ldap.find_entries(filter=self.filter, base_dn=self.base_dn) + dns_enabled = True + except errors.EmptyResult: + dns_enabled = False + + return dict(result=dns_enabled, value=pkey_to_value(None, options)) + + +@register() +class dnsconfig(LDAPObject): + """ + DNS global configuration object + """ + object_name = _('DNS configuration options') + default_attributes = [ + 'idnsforwardpolicy', 'idnsforwarders', 'idnsallowsyncptr' + ] + + label = _('DNS Global Configuration') + label_singular = _('DNS Global Configuration') + + takes_params = ( + Str('idnsforwarders*', + _validate_bind_forwarder, + cli_name='forwarder', + label=_('Global forwarders'), + doc=_('Global forwarders. A custom port can be specified for each ' + 'forwarder using a standard format "IP_ADDRESS port PORT"'), + ), + StrEnum('idnsforwardpolicy?', + cli_name='forward_policy', + label=_('Forward policy'), + doc=_('Global forwarding policy. Set to "none" to disable ' + 'any configured global forwarders.'), + values=(u'only', u'first', u'none'), + ), + Bool('idnsallowsyncptr?', + cli_name='allow_sync_ptr', + label=_('Allow PTR sync'), + doc=_('Allow synchronization of forward (A, AAAA) and reverse (PTR) records'), + ), + Int('idnszonerefresh?', + deprecated=True, + cli_name='zone_refresh', + label=_('Zone refresh interval'), + doc=_('An interval between regular polls of the name server for new DNS zones'), + minvalue=0, + flags={'no_option'}, + ), + Int('ipadnsversion?', # available only in installer/upgrade + label=_('IPA DNS version'), + ), + ) + managed_permissions = { + 'System: Write DNS Configuration': { + 'non_object': True, + 'ipapermright': {'write'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsConfigObject)'], + 'ipapermdefaultattr': { + 'idnsallowsyncptr', 'idnsforwarders', 'idnsforwardpolicy', + 'idnspersistentsearch', 'idnszonerefresh' + }, + 'replaces': [ + '(targetattr = "idnsforwardpolicy || idnsforwarders || idnsallowsyncptr || idnszonerefresh || idnspersistentsearch")(target = "ldap:///cn=dns,$SUFFIX")(version 3.0;acl "permission:Write DNS Configuration";allow (write) groupdn = "ldap:///cn=Write DNS Configuration,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Read DNS Configuration': { + 'non_object': True, + 'ipapermright': {'read'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsConfigObject)'], + 'ipapermdefaultattr': { + 'objectclass', + 'idnsallowsyncptr', 'idnsforwarders', 'idnsforwardpolicy', + 'idnspersistentsearch', 'idnszonerefresh', 'ipadnsversion' + }, + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + } + + def get_dn(self, *keys, **kwargs): + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + return DN(api.env.container_dns, api.env.basedn) + + def get_dnsconfig(self, ldap): + entry = ldap.get_entry(self.get_dn(), None) + + return entry + + def postprocess_result(self, result): + if not any(param in result['result'] for param in self.params): + result['summary'] = unicode(_('Global DNS configuration is empty')) + + +@register() +class dnsconfig_mod(LDAPUpdate): + __doc__ = _('Modify global DNS configuration.') + + def get_options(self): + """hide ipadnsversion outside of installer/upgrade""" + for option in super(dnsconfig_mod, self).get_options(): + if option.name == 'ipadnsversion': + option = option.clone(include=('installer', 'updates')) + yield option + + def execute(self, *keys, **options): + # test dnssec forwarders + forwarders = options.get('idnsforwarders') + + result = super(dnsconfig_mod, self).execute(*keys, **options) + self.obj.postprocess_result(result) + + # this check makes sense only when resulting forwarders are non-empty + if result['result'].get('idnsforwarders'): + fwzone = DNSName('.') + _add_warning_fw_policy_conflict_aez(result, fwzone, **options) + + if forwarders: + # forwarders were changed + for forwarder in forwarders: + try: + validate_dnssec_global_forwarder(forwarder, log=self.log) + except DNSSECSignatureMissingError as e: + messages.add_message( + options['version'], + result, messages.DNSServerDoesNotSupportDNSSECWarning( + server=forwarder, error=e, + ) + ) + except EDNS0UnsupportedError as e: + messages.add_message( + options['version'], + result, messages.DNSServerDoesNotSupportEDNS0Warning( + server=forwarder, error=e, + ) + ) + except UnresolvableRecordError as e: + messages.add_message( + options['version'], + result, messages.DNSServerValidationWarning( + server=forwarder, error=e + ) + ) + + return result + + + +@register() +class dnsconfig_show(LDAPRetrieve): + __doc__ = _('Show the current global DNS configuration.') + + def execute(self, *keys, **options): + result = super(dnsconfig_show, self).execute(*keys, **options) + self.obj.postprocess_result(result) + return result + + + +@register() +class dnsforwardzone(DNSZoneBase): + """ + DNS Forward zone, container for resource records. + """ + object_name = _('DNS forward zone') + object_name_plural = _('DNS forward zones') + object_class = DNSZoneBase.object_class + ['idnsforwardzone'] + label = _('DNS Forward Zones') + label_singular = _('DNS Forward Zone') + default_forward_policy = u'first' + + # managed_permissions: permissions was apllied in dnszone class, do NOT + # add them here, they should not be applied twice. + + def _warning_fw_zone_is_not_effective(self, result, *keys, **options): + fwzone = keys[-1] + _add_warning_fw_zone_is_not_effective(self.api, result, fwzone, + options['version']) + + def _warning_if_forwarders_do_not_work(self, result, new_zone, + *keys, **options): + fwzone = keys[-1] + forwarders = options.get('idnsforwarders', []) + any_forwarder_work = False + + for forwarder in forwarders: + try: + validate_dnssec_zone_forwarder_step1(forwarder, fwzone, + log=self.log) + except UnresolvableRecordError as e: + messages.add_message( + options['version'], + result, messages.DNSServerValidationWarning( + server=forwarder, error=e + ) + ) + except EDNS0UnsupportedError as e: + messages.add_message( + options['version'], + result, messages.DNSServerDoesNotSupportEDNS0Warning( + server=forwarder, error=e + ) + ) + else: + any_forwarder_work = True + + if not any_forwarder_work: + # do not test DNSSEC validation if there is no valid forwarder + return + + # resolve IP address of any DNS replica + # FIXME: https://fedorahosted.org/bind-dyndb-ldap/ticket/143 + # we currenly should to test all IPA DNS replica, because DNSSEC + # validation is configured just in named.conf per replica + + ipa_dns_masters = [normalize_zone(x) for x in + self.api.Object.dnsrecord.get_dns_masters()] + + if not ipa_dns_masters: + # something very bad happened, DNS is installed, but no IPA DNS + # servers available + self.log.error("No IPA DNS server can be found, but integrated DNS " + "is installed") + return + + ipa_dns_ip = None + for rdtype in (dns.rdatatype.A, dns.rdatatype.AAAA): + try: + ans = dns.resolver.query(ipa_dns_masters[0], rdtype) + except dns.exception.DNSException: + continue + else: + ipa_dns_ip = str(ans.rrset.items[0]) + break + + if not ipa_dns_ip: + self.log.error("Cannot resolve %s hostname", ipa_dns_masters[0]) + return + + # sleep a bit, adding new zone to BIND from LDAP may take a while + if new_zone: + time.sleep(5) + + # Test if IPA is able to receive replies from forwarders + try: + validate_dnssec_zone_forwarder_step2(ipa_dns_ip, fwzone, + log=self.log) + except DNSSECValidationError as e: + messages.add_message( + options['version'], + result, messages.DNSSECValidationFailingWarning(error=e) + ) + except UnresolvableRecordError as e: + messages.add_message( + options['version'], + result, messages.DNSServerValidationWarning( + server=ipa_dns_ip, error=e + ) + ) + + +@register() +class dnsforwardzone_add(DNSZoneBase_add): + __doc__ = _('Create new DNS forward zone.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + dn = super(dnsforwardzone_add, self).pre_callback(ldap, dn, + entry_attrs, attrs_list, *keys, **options) + + if 'idnsforwardpolicy' not in entry_attrs: + entry_attrs['idnsforwardpolicy'] = self.obj.default_forward_policy + + if (not entry_attrs.get('idnsforwarders') and + entry_attrs['idnsforwardpolicy'] != u'none'): + raise errors.ValidationError(name=u'idnsforwarders', + error=_('Please specify forwarders.')) + + return dn + + def execute(self, *keys, **options): + fwzone = keys[-1] + result = super(dnsforwardzone_add, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + _add_warning_fw_policy_conflict_aez(result, fwzone, **options) + if options.get('idnsforwarders'): + self.obj._warning_if_forwarders_do_not_work( + result, True, *keys, **options) + return result + + +@register() +class dnsforwardzone_del(DNSZoneBase_del): + __doc__ = _('Delete DNS forward zone.') + + msg_summary = _('Deleted DNS forward zone "%(value)s"') + + +@register() +class dnsforwardzone_mod(DNSZoneBase_mod): + __doc__ = _('Modify DNS forward zone.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + try: + entry = ldap.get_entry(dn) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + policy = self.obj.default_forward_policy + forwarders = [] + + if 'idnsforwarders' in entry_attrs: + forwarders = entry_attrs['idnsforwarders'] + elif 'idnsforwarders' in entry: + forwarders = entry['idnsforwarders'] + + if 'idnsforwardpolicy' in entry_attrs: + policy = entry_attrs['idnsforwardpolicy'] + elif 'idnsforwardpolicy' in entry: + policy = entry['idnsforwardpolicy'] + + if not forwarders and policy != u'none': + raise errors.ValidationError(name=u'idnsforwarders', + error=_('Please specify forwarders.')) + + return dn + + def execute(self, *keys, **options): + fwzone = keys[-1] + result = super(dnsforwardzone_mod, self).execute(*keys, **options) + _add_warning_fw_policy_conflict_aez(result, fwzone, **options) + if options.get('idnsforwarders'): + self.obj._warning_if_forwarders_do_not_work(result, False, *keys, + **options) + return result + +@register() +class dnsforwardzone_find(DNSZoneBase_find): + __doc__ = _('Search for DNS forward zones.') + + +@register() +class dnsforwardzone_show(DNSZoneBase_show): + __doc__ = _('Display information about a DNS forward zone.') + + has_output_params = LDAPRetrieve.has_output_params + dnszone_output_params + + +@register() +class dnsforwardzone_disable(DNSZoneBase_disable): + __doc__ = _('Disable DNS Forward Zone.') + msg_summary = _('Disabled DNS forward zone "%(value)s"') + + +@register() +class dnsforwardzone_enable(DNSZoneBase_enable): + __doc__ = _('Enable DNS Forward Zone.') + msg_summary = _('Enabled DNS forward zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnsforwardzone_enable, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + return result + + +@register() +class dnsforwardzone_add_permission(DNSZoneBase_add_permission): + __doc__ = _('Add a permission for per-forward zone access delegation.') + + +@register() +class dnsforwardzone_remove_permission(DNSZoneBase_remove_permission): + __doc__ = _('Remove a permission for per-forward zone access delegation.') |