1#!/usr/bin/env python
2# Copyright 2009 Google Inc. All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Module for all nameserver related activity."""
17
18__author__ = 'tstromberg@google.com (Thomas Stromberg)'
19
20import random
21import re
22import socket
23import sys
24import time
25
26
27if __name__ == '__main__':
28  sys.path.append('../nb_third_party')
29
30# external dependencies (from nb_third_party)
31import dns.exception
32import dns.message
33import dns.name
34import dns.query
35import dns.rcode
36import dns.rdataclass
37import dns.rdatatype
38import dns.resolver
39import dns.reversename
40import dns.version
41
42import health_checks
43import util
44
45# Look for buggy system versions of namebench
46if dns.version.hexversion < 17301744:
47  raise ValueError('dnspython 1.8.0+ required, while only %s was found. The '
48                   'namebench source bundles 1.8.0, so use it.' % dns.version.version)
49
50
51
52# How many failures before we disable system nameservers
53MAX_NORMAL_FAILURES = 2
54MAX_SYSTEM_FAILURES = 7
55MAX_PREFERRED_FAILURES = 5
56MAX_WARNINGS = 7
57
58FAILURE_PRONE_RATE = 10
59
60
61def _DoesClockGoBackwards():
62  """Detect buggy Windows systems where time.clock goes backwards"""
63  reference = 0
64  print "Checking if time.clock() goes backwards (broken hardware)..."
65  for x in range(0, 200):
66    counter = time.clock()
67    if counter < reference:
68      print "Clock went backwards by %fms" % (counter - reference)
69      return True
70    reference = counter
71    time.sleep(random.random() / 500)
72  return False
73
74def _GetBestTimer():
75  """Pick the most accurate timer for a platform."""
76  if sys.platform[:3] == 'win' and not _DoesClockGoBackwards():
77    return time.clock
78  else:
79    return time.time
80
81
82# EVIL IMPORT-TIME SIDE EFFECT
83BEST_TIMER_FUNCTION = _GetBestTimer()
84
85
86def ResponseToAscii(response):
87  if not response:
88    return None
89  if response.answer:
90    answers = [', '.join(map(str, x.items)) for x in response.answer]
91    return ' -> '.join(answers).rstrip('"').lstrip('"')
92  else:
93    return dns.rcode.to_text(response.rcode())
94
95
96class BrokenSystemClock(Exception):
97  """Used if we detect errors with the system clock."""
98  def __init__(self, value):
99    self.value = value
100
101  def __str__(self):
102    return repr(self.value)
103
104
105class NameServer(health_checks.NameServerHealthChecks):
106  """Hold information about a particular nameserver."""
107
108  def __init__(self, ip, name=None, internal=False, preferred=False):
109    self.name = name
110    # We use _ for IPV6 representation in our configuration due to ConfigParser issues.
111    self.ip = ip.replace('_', ':')
112    self.is_system = internal
113    self.is_regional = False
114    self.is_global = False
115    self.is_custom = False
116    self.system_position = None
117    self.is_preferred = preferred
118    self.timeout = 5
119    self.health_timeout = 5
120    self.ping_timeout = 1
121    self.ResetTestStatus()
122    self.port_behavior = None
123    self._version = None
124    self._node_ids = set()
125    self._hostname = None
126    self.timer = BEST_TIMER_FUNCTION
127
128    if ':' in self.ip:
129      self.is_ipv6 = True
130    else:
131      self.is_ipv6 = False
132
133  @property
134  def check_average(self):
135    # If we only have a ping result, sort by it. Otherwise, use all non-ping results.
136    if len(self.checks) == 1:
137      return self.checks[0][3]
138    else:
139      return util.CalculateListAverage([x[3] for x in self.checks[1:]])
140
141  @property
142  def fastest_check_duration(self):
143    if self.checks:
144      return min([x[3] for x in self.checks])
145    else:
146      return 0.0
147
148  @property
149  def slowest_check_duration(self):
150    if self.checks:
151      return max([x[3] for x in self.checks])
152    else:
153      return None
154
155  @property
156  def check_duration(self):
157    return sum([x[3] for x in self.checks])
158
159  @property
160  def warnings_string(self):
161    if self.disabled:
162      return '(excluded: %s)' % self.disabled
163    else:
164      return ', '.join(map(str, self.warnings))
165
166  @property
167  def warnings_comment(self):
168    if self.warnings or self.disabled:
169      return '# ' + self.warnings_string
170    else:
171      return ''
172
173  @property
174  def errors(self):
175    return ['%s (%s requests)' % (_[0], _[1]) for _ in self.error_map.items() if _[0] != 'Timeout']
176
177  @property
178  def error_count(self):
179    return sum([_[1] for _ in self.error_map.items() if _[0] != 'Timeout'])
180
181  @property
182  def timeout_count(self):
183    return self.error_map.get('Timeout', 0)
184
185  @property
186  def notes(self):
187    """Return a list of notes about this nameserver object."""
188    my_notes = []
189    if self.system_position == 0:
190      my_notes.append('The current preferred DNS server.')
191    elif self.system_position:
192      my_notes.append('A backup DNS server for this system.')
193    if self.is_failure_prone:
194      my_notes.append('%0.0f queries to this host failed' % self.failure_rate)
195    if self.port_behavior and 'POOR' in self.port_behavior:
196      my_notes.append('Vulnerable to poisoning attacks (poor port diversity)')
197    if self.disabled:
198      my_notes.append(self.disabled)
199    else:
200      my_notes.extend(self.warnings)
201    if self.errors:
202      my_notes.extend(self.errors)
203    return my_notes
204
205  @property
206  def hostname(self):
207    if self._hostname is None:
208      self._hostname = self.RequestReverseIP(self.ip)
209    return self._hostname
210
211  @property
212  def version(self):
213    if self._version is None:
214      self.RequestVersion()
215    return self._version
216
217  @property
218  def node_ids(self):
219    """Return a set of node_ids seen on this system."""
220    # We use a slightly different pattern here because we want to
221    # append to our results each time this is called.
222    self.RequestNodeId()
223    # Only return non-blank entries
224    return [x for x in self._node_ids if x]
225
226  @property
227  def partial_node_ids(self):
228    partials = []
229    for node_id in self._node_ids:
230      node_bits = node_id.split('.')
231      if len(node_bits) >= 3:
232        partials.append('.'.join(node_bits[0:-2]))
233      else:
234        partials.append('.'.join(node_bits))
235    return partials
236
237  @property
238  def name_and_node(self):
239    if self.node_ids:
240      return '%s [%s]' % (self.name, ', '.join(self.partial_node_ids))
241    else:
242      return self.name
243
244  @property
245  def is_failure_prone(self):
246    if self.failure_rate >= FAILURE_PRONE_RATE:
247      return True
248    else:
249      return False
250
251  @property
252  def failure_rate(self):
253    if not self.failure_count or not self.request_count:
254      return 0
255    else:
256      return (float(self.failure_count) / float(self.request_count)) * 100
257
258  def __str__(self):
259    return '%s [%s]' % (self.name, self.ip)
260
261  def __repr__(self):
262    return self.__str__()
263
264  def ResetTestStatus(self):
265    """Reset testing status of this host."""
266    self.warnings = set()
267    self.shared_with = set()
268    self.disabled = False
269    self.checks = []
270    self.failed_test_count = 0
271    self.share_check_count = 0
272    self.cache_checks = []
273    self.is_slower_replica = False
274    self.ResetErrorCounts()
275
276  def ResetErrorCounts(self):
277    """NOTE: This gets called by benchmark.Run()!"""
278
279    self.request_count = 0
280    self.failure_count = 0
281    self.error_map = {}
282
283  def AddFailure(self, message, fatal=False):
284    """Add a failure for this nameserver. This will effectively disable it's use."""
285    if self.is_system:
286      max_count = MAX_SYSTEM_FAILURES
287    elif self.is_preferred:
288      max_count = MAX_PREFERRED_FAILURES
289    else:
290      max_count = MAX_NORMAL_FAILURES
291
292    self.failed_test_count += 1
293
294    if self.is_system or self.is_preferred:
295      # If the preferred host is IPv6 and we have no previous checks, fail quietly.
296      if self.is_ipv6 and len(self.checks) <= 1:
297        self.disabled = message
298      else:
299        print "\n* %s failed test #%s/%s: %s" % (self, self.failed_test_count, max_count, message)
300
301    # Fatal doesn't count for system & preferred nameservers.
302    if fatal and not (self.is_system or self.is_preferred):
303      self.disabled = message
304    elif self.failed_test_count >= max_count:
305      self.disabled = "Failed %s tests, last: %s" % (self.failed_test_count, message)
306
307  def AddWarning(self, message, penalty=True):
308    """Add a warning to a host."""
309
310    if not isinstance(message, str):
311      print "Tried to add %s to %s (not a string)" % (message, self)
312      return None
313
314    self.warnings.add(message)
315    if penalty and len(self.warnings) >= MAX_WARNINGS:
316      self.AddFailure('Too many warnings (%s), probably broken.' % len(self.warnings), fatal=True)
317
318  def CreateRequest(self, record, request_type, return_type):
319    """Function to work around any dnspython make_query quirks."""
320    return dns.message.make_query(record, request_type, return_type)
321
322  def Query(self, request, timeout):
323    return dns.query.udp(request, self.ip, timeout, 53)
324
325  def TimedRequest(self, type_string, record_string, timeout=None, rdataclass=None):
326    """Make a DNS request, returning the reply and duration it took.
327
328    Args:
329      type_string: DNS record type to query (string)
330      record_string: DNS record name to query (string)
331      timeout: optional timeout (float)
332      rdataclass: optional result class (defaults to rdataclass.IN)
333
334    Returns:
335      A tuple of (response, duration in ms [float], error_msg)
336
337    In the case of a DNS response timeout, the response object will be None.
338    """
339    if not rdataclass:
340      rdataclass = dns.rdataclass.IN
341    else:
342      rdataclass = dns.rdataclass.from_text(rdataclass)
343
344    request_type = dns.rdatatype.from_text(type_string)
345    record = dns.name.from_text(record_string, None)
346    request = None
347    self.request_count += 1
348
349    # Sometimes it takes great effort just to craft a UDP packet.
350    try:
351      request = self.CreateRequest(record, request_type, rdataclass)
352    except ValueError, exc:
353      if not request:
354        return (None, 0, util.GetLastExceptionString())
355
356    if not timeout:
357      timeout = self.timeout
358
359    error_msg = None
360    exc = None
361    duration = None
362    try:
363      start_time = self.timer()
364      response = self.Query(request, timeout)
365      duration = self.timer() - start_time
366    except (dns.exception.Timeout), exc:
367      response = None
368    except (dns.query.BadResponse, dns.message.TrailingJunk,
369            dns.query.UnexpectedSource), exc:
370      error_msg = util.GetLastExceptionString()
371      response = None
372    # This is pretty normal if someone runs namebench offline.
373    except socket.error:
374      response = None
375      if ':' in self.ip:
376        error_msg = 'socket error: IPv6 may not be available.'
377      else:
378        error_msg = util.GetLastExceptionString()
379    # Pass these exceptions up the food chain
380    except (KeyboardInterrupt, SystemExit, SystemError), exc:
381      raise exc
382    except:
383      error_msg = util.GetLastExceptionString()
384      print "* Unusual error with %s:%s on %s: %s" % (type_string, record_string, self, error_msg)
385      response = None
386
387    if not response:
388      self.failure_count += 1
389
390    if not duration:
391      duration = self.timer() - start_time
392
393    if exc and not error_msg:
394      error_msg = '%s: %s' % (record_string, util.GetLastExceptionString())
395
396    if error_msg:
397      key = util.GetLastExceptionString()
398      self.error_map[key] = self.error_map.setdefault(key, 0) + 1
399
400    if duration < 0:
401      raise BrokenSystemClock('The time on your machine appears to be going backwards. '
402                              'We cannot accurately benchmark due to this error. '
403                              '(timer=%s, duration=%s)' % (self.timer, duration))
404    return (response, util.SecondsToMilliseconds(duration), error_msg)
405
406  def RequestVersion(self):
407    version = ''
408    (response, duration, error_msg) = self.TimedRequest('TXT', 'version.bind.', rdataclass='CHAOS',
409                                                        timeout=self.health_timeout*2)
410    if response and response.answer:
411      response_string = ResponseToAscii(response)
412      if (re.search('\d', response_string) or
413          (re.search('recursive|ns|server|bind|unbound', response_string, re.I)
414           and 'ontact' not in response_string and '...' not in response_string)):
415        version = response_string
416    self._version = version
417    return (version, duration, error_msg)
418
419  def RequestReverseIP(self, ip):
420    """Request a hostname for a given IP address."""
421    try:
422      answer = dns.resolver.query(dns.reversename.from_address(ip), 'PTR')
423    except:
424      answer = None
425    if answer:
426      return answer[0].to_text().rstrip('.')
427    else:
428      return ip
429
430  def RequestNodeId(self):
431    """Try to determine the node id for this nameserver (tries many methods)."""
432    node = ''
433    rdataclass = None
434    reverse_lookup = False
435
436    if self.hostname.endswith('ultradns.net') or self.ip.startswith('156.154.7'):
437      query_type, record_name = ('A', 'whoareyou.ultradns.net.')
438      reverse_lookup = True
439    elif self.ip.startswith('8.8'):
440      query_type, record_name = ('A', 'self.myresolver.info.')
441      reverse_lookup = True
442    elif self.hostname.endswith('opendns.com') or self.ip.startswith('208.67.22'):
443      query_type, record_name = ('TXT', 'which.opendns.com.')
444    else:
445      query_type, record_name, rdataclass = ('TXT', 'hostname.bind.', 'CHAOS')
446
447    (response, duration, error_msg) = self.TimedRequest(query_type, record_name, rdataclass=rdataclass,
448                                                        timeout=self.health_timeout*2)
449    if not response or not response.answer:
450      query_type, record_name, rdataclass = ('TXT', 'id.server.', 'CHAOS')
451      (response, duration, error_msg) = self.TimedRequest(query_type, record_name, rdataclass=rdataclass,
452                                                          timeout=self.health_timeout*2)
453
454    if response and response.answer:
455      node = ResponseToAscii(response)
456      if reverse_lookup:
457        node = self.RequestReverseIP(node)
458
459    # This is what the .node* properties use.
460    self._node_ids.add(node)
461    return (node, duration, error_msg)
462
463
464if __name__ == '__main__':
465  ns = NameServer(sys.argv[1])
466  print "-" * 64
467  print "IP:      %s" % ns.ip
468  print "Host:    %s" % ns.hostname
469  print "Version: %s" % ns.version
470  print "Node:    %s" % ns.node_ids
471