1# -*- coding: utf-8 -*-
2# (c) 2015, Jan-Piet Mens <jpmens(at)gmail.com>
3# (c) 2017 Ansible Project
4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5from __future__ import (absolute_import, division, print_function)
6__metaclass__ = type
7
8DOCUMENTATION = '''
9    name: dig
10    author: Jan-Piet Mens (@jpmens) <jpmens(at)gmail.com>
11    short_description: query DNS using the dnspython library
12    requirements:
13      - dnspython (python library, http://www.dnspython.org/)
14    description:
15      - The dig lookup runs queries against DNS servers to retrieve DNS records for a specific name (FQDN - fully qualified domain name).
16        It is possible to lookup any DNS record in this manner.
17      - There is a couple of different syntaxes that can be used to specify what record should be retrieved, and for which name.
18         It is also possible to explicitly specify the DNS server(s) to use for lookups.
19      - In its simplest form, the dig lookup plugin can be used to retrieve an IPv4 address (DNS A record) associated with FQDN
20      - In addition to (default) A record, it is also possible to specify a different record type that should be queried.
21        This can be done by either passing-in additional parameter of format qtype=TYPE to the dig lookup, or by appending /TYPE to the FQDN being queried.
22      - If multiple values are associated with the requested record, the results will be returned as a comma-separated list.
23        In such cases you may want to pass option wantlist=True to the plugin, which will result in the record values being returned as a list
24        over which you can iterate later on.
25      - By default, the lookup will rely on system-wide configured DNS servers for performing the query.
26        It is also possible to explicitly specify DNS servers to query using the @DNS_SERVER_1,DNS_SERVER_2,...,DNS_SERVER_N notation.
27        This needs to be passed-in as an additional parameter to the lookup
28    options:
29      _terms:
30        description: domain(s) to query
31      qtype:
32        description: record type to query
33        default: 'A'
34        choices: [A, ALL, AAAA, CNAME, DNAME, DLV, DNSKEY, DS, HINFO, LOC, MX, NAPTR, NS, NSEC3PARAM, PTR, RP, RRSIG, SOA, SPF, SRV, SSHFP, TLSA, TXT]
35      flat:
36        description: If 0 each record is returned as a dictionary, otherwise a string
37        default: 1
38      retry_servfail:
39        description: Retry a nameserver if it returns SERVFAIL.
40        default: false
41        type: bool
42        version_added: 3.6.0
43    notes:
44      - ALL is not a record per-se, merely the listed fields are available for any record results you retrieve in the form of a dictionary.
45      - While the 'dig' lookup plugin supports anything which dnspython supports out of the box, only a subset can be converted into a dictionary.
46      - If you need to obtain the AAAA record (IPv6 address), you must specify the record type explicitly.
47        Syntax for specifying the record type is shown in the examples below.
48      - The trailing dot in most of the examples listed is purely optional, but is specified for completeness/correctness sake.
49'''
50
51EXAMPLES = """
52- name: Simple A record (IPV4 address) lookup for example.com
53  ansible.builtin.debug:
54    msg: "{{ lookup('community.general.dig', 'example.com.')}}"
55
56- name: "The TXT record for example.org."
57  ansible.builtin.debug:
58    msg: "{{ lookup('community.general.dig', 'example.org.', 'qtype=TXT') }}"
59
60- name: "The TXT record for example.org, alternative syntax."
61  ansible.builtin.debug:
62    msg: "{{ lookup('community.general.dig', 'example.org./TXT') }}"
63
64- name: use in a loop
65  ansible.builtin.debug:
66    msg: "MX record for gmail.com {{ item }}"
67  with_items: "{{ lookup('community.general.dig', 'gmail.com./MX', wantlist=True) }}"
68
69- ansible.builtin.debug:
70    msg: "Reverse DNS for 192.0.2.5 is {{ lookup('community.general.dig', '192.0.2.5/PTR') }}"
71- ansible.builtin.debug:
72    msg: "Reverse DNS for 192.0.2.5 is {{ lookup('community.general.dig', '5.2.0.192.in-addr.arpa./PTR') }}"
73- ansible.builtin.debug:
74    msg: "Reverse DNS for 192.0.2.5 is {{ lookup('community.general.dig', '5.2.0.192.in-addr.arpa.', 'qtype=PTR') }}"
75- ansible.builtin.debug:
76    msg: "Querying 198.51.100.23 for IPv4 address for example.com. produces {{ lookup('dig', 'example.com', '@198.51.100.23') }}"
77
78- ansible.builtin.debug:
79    msg: "XMPP service for gmail.com. is available at {{ item.target }} on port {{ item.port }}"
80  with_items: "{{ lookup('community.general.dig', '_xmpp-server._tcp.gmail.com./SRV', 'flat=0', wantlist=True) }}"
81
82- name: Retry nameservers that return SERVFAIL
83  ansible.builtin.debug:
84    msg: "{{ lookup('community.general.dig', 'example.org./A', 'retry_servfail=True') }}"
85"""
86
87RETURN = """
88  _list:
89    description:
90      - List of composed strings or dictionaries with key and value
91        If a dictionary, fields shows the keys returned depending on query type
92    type: list
93    elements: raw
94    contains:
95       ALL:
96           description:
97               - owner, ttl, type
98       A:
99           description:
100               - address
101       AAAA:
102           description:
103               - address
104       CNAME:
105           description:
106               - target
107       DNAME:
108           description:
109               - target
110       DLV:
111            description:
112                - algorithm, digest_type, key_tag, digest
113       DNSKEY:
114            description:
115                - flags, algorithm, protocol, key
116       DS:
117            description:
118                - algorithm, digest_type, key_tag, digest
119       HINFO:
120            description:
121                -  cpu, os
122       LOC:
123            description:
124                - latitude, longitude, altitude, size, horizontal_precision, vertical_precision
125       MX:
126            description:
127                - preference, exchange
128       NAPTR:
129            description:
130                - order, preference, flags, service, regexp, replacement
131       NS:
132            description:
133                - target
134       NSEC3PARAM:
135            description:
136                - algorithm, flags, iterations, salt
137       PTR:
138            description:
139                - target
140       RP:
141            description:
142                - mbox, txt
143       SOA:
144            description:
145                - mname, rname, serial, refresh, retry, expire, minimum
146       SPF:
147            description:
148                - strings
149       SRV:
150            description:
151                - priority, weight, port, target
152       SSHFP:
153            description:
154                - algorithm, fp_type, fingerprint
155       TLSA:
156            description:
157                - usage, selector, mtype, cert
158       TXT:
159            description:
160                - strings
161"""
162
163from ansible.errors import AnsibleError
164from ansible.plugins.lookup import LookupBase
165from ansible.module_utils.common.text.converters import to_native
166import socket
167
168try:
169    import dns.exception
170    import dns.name
171    import dns.resolver
172    import dns.reversename
173    import dns.rdataclass
174    from dns.rdatatype import (A, AAAA, CNAME, DLV, DNAME, DNSKEY, DS, HINFO, LOC,
175                               MX, NAPTR, NS, NSEC3PARAM, PTR, RP, SOA, SPF, SRV, SSHFP, TLSA, TXT)
176    HAVE_DNS = True
177except ImportError:
178    HAVE_DNS = False
179
180
181def make_rdata_dict(rdata):
182    ''' While the 'dig' lookup plugin supports anything which dnspython supports
183        out of the box, the following supported_types list describes which
184        DNS query types we can convert to a dict.
185
186        Note: adding support for RRSIG is hard work. :)
187    '''
188    supported_types = {
189        A: ['address'],
190        AAAA: ['address'],
191        CNAME: ['target'],
192        DNAME: ['target'],
193        DLV: ['algorithm', 'digest_type', 'key_tag', 'digest'],
194        DNSKEY: ['flags', 'algorithm', 'protocol', 'key'],
195        DS: ['algorithm', 'digest_type', 'key_tag', 'digest'],
196        HINFO: ['cpu', 'os'],
197        LOC: ['latitude', 'longitude', 'altitude', 'size', 'horizontal_precision', 'vertical_precision'],
198        MX: ['preference', 'exchange'],
199        NAPTR: ['order', 'preference', 'flags', 'service', 'regexp', 'replacement'],
200        NS: ['target'],
201        NSEC3PARAM: ['algorithm', 'flags', 'iterations', 'salt'],
202        PTR: ['target'],
203        RP: ['mbox', 'txt'],
204        # RRSIG: ['algorithm', 'labels', 'original_ttl', 'expiration', 'inception', 'signature'],
205        SOA: ['mname', 'rname', 'serial', 'refresh', 'retry', 'expire', 'minimum'],
206        SPF: ['strings'],
207        SRV: ['priority', 'weight', 'port', 'target'],
208        SSHFP: ['algorithm', 'fp_type', 'fingerprint'],
209        TLSA: ['usage', 'selector', 'mtype', 'cert'],
210        TXT: ['strings'],
211    }
212
213    rd = {}
214
215    if rdata.rdtype in supported_types:
216        fields = supported_types[rdata.rdtype]
217        for f in fields:
218            val = rdata.__getattribute__(f)
219
220            if isinstance(val, dns.name.Name):
221                val = dns.name.Name.to_text(val)
222
223            if rdata.rdtype == DLV and f == 'digest':
224                val = dns.rdata._hexify(rdata.digest).replace(' ', '')
225            if rdata.rdtype == DS and f == 'digest':
226                val = dns.rdata._hexify(rdata.digest).replace(' ', '')
227            if rdata.rdtype == DNSKEY and f == 'key':
228                val = dns.rdata._base64ify(rdata.key).replace(' ', '')
229            if rdata.rdtype == NSEC3PARAM and f == 'salt':
230                val = dns.rdata._hexify(rdata.salt).replace(' ', '')
231            if rdata.rdtype == SSHFP and f == 'fingerprint':
232                val = dns.rdata._hexify(rdata.fingerprint).replace(' ', '')
233            if rdata.rdtype == TLSA and f == 'cert':
234                val = dns.rdata._hexify(rdata.cert).replace(' ', '')
235
236            rd[f] = val
237
238    return rd
239
240
241# ==============================================================
242# dig: Lookup DNS records
243#
244# --------------------------------------------------------------
245
246class LookupModule(LookupBase):
247
248    def run(self, terms, variables=None, **kwargs):
249
250        '''
251        terms contains a string with things to `dig' for. We support the
252        following formats:
253            example.com                                     # A record
254            example.com  qtype=A                            # same
255            example.com/TXT                                 # specific qtype
256            example.com  qtype=txt                          # same
257            192.0.2.23/PTR                                 # reverse PTR
258              ^^ shortcut for 23.2.0.192.in-addr.arpa/PTR
259            example.net/AAAA  @nameserver                   # query specified server
260                               ^^^ can be comma-sep list of names/addresses
261
262            ... flat=0                                      # returns a dict; default is 1 == string
263        '''
264
265        if HAVE_DNS is False:
266            raise AnsibleError("The dig lookup requires the python 'dnspython' library and it is not installed")
267
268        # Create Resolver object so that we can set NS if necessary
269        myres = dns.resolver.Resolver(configure=True)
270        edns_size = 4096
271        myres.use_edns(0, ednsflags=dns.flags.DO, payload=edns_size)
272
273        domain = None
274        qtype = 'A'
275        flat = True
276        rdclass = dns.rdataclass.from_text('IN')
277
278        for t in terms:
279            if t.startswith('@'):       # e.g. "@10.0.1.2,192.0.2.1" is ok.
280                nsset = t[1:].split(',')
281                for ns in nsset:
282                    nameservers = []
283                    # Check if we have a valid IP address. If so, use that, otherwise
284                    # try to resolve name to address using system's resolver. If that
285                    # fails we bail out.
286                    try:
287                        socket.inet_aton(ns)
288                        nameservers.append(ns)
289                    except Exception:
290                        try:
291                            nsaddr = dns.resolver.query(ns)[0].address
292                            nameservers.append(nsaddr)
293                        except Exception as e:
294                            raise AnsibleError("dns lookup NS: %s" % to_native(e))
295                    myres.nameservers = nameservers
296                continue
297            if '=' in t:
298                try:
299                    opt, arg = t.split('=')
300                except Exception:
301                    pass
302
303                if opt == 'qtype':
304                    qtype = arg.upper()
305                elif opt == 'flat':
306                    flat = int(arg)
307                elif opt == 'class':
308                    try:
309                        rdclass = dns.rdataclass.from_text(arg)
310                    except Exception as e:
311                        raise AnsibleError("dns lookup illegal CLASS: %s" % to_native(e))
312                elif opt == 'retry_servfail':
313                    myres.retry_servfail = bool(arg)
314
315                continue
316
317            if '/' in t:
318                try:
319                    domain, qtype = t.split('/')
320                except Exception:
321                    domain = t
322            else:
323                domain = t
324
325        # print "--- domain = {0} qtype={1} rdclass={2}".format(domain, qtype, rdclass)
326
327        ret = []
328
329        if qtype.upper() == 'PTR':
330            try:
331                n = dns.reversename.from_address(domain)
332                domain = n.to_text()
333            except dns.exception.SyntaxError:
334                pass
335            except Exception as e:
336                raise AnsibleError("dns.reversename unhandled exception %s" % to_native(e))
337
338        try:
339            answers = myres.query(domain, qtype, rdclass=rdclass)
340            for rdata in answers:
341                s = rdata.to_text()
342                if qtype.upper() == 'TXT':
343                    s = s[1:-1]  # Strip outside quotes on TXT rdata
344
345                if flat:
346                    ret.append(s)
347                else:
348                    try:
349                        rd = make_rdata_dict(rdata)
350                        rd['owner'] = answers.canonical_name.to_text()
351                        rd['type'] = dns.rdatatype.to_text(rdata.rdtype)
352                        rd['ttl'] = answers.rrset.ttl
353                        rd['class'] = dns.rdataclass.to_text(rdata.rdclass)
354
355                        ret.append(rd)
356                    except Exception as e:
357                        ret.append(str(e))
358
359        except dns.resolver.NXDOMAIN:
360            ret.append('NXDOMAIN')
361        except dns.resolver.NoAnswer:
362            ret.append("")
363        except dns.resolver.Timeout:
364            ret.append('')
365        except dns.exception.DNSException as e:
366            raise AnsibleError("dns.resolver unhandled exception %s" % to_native(e))
367
368        return ret
369