Browse Source

Improve fallback selection and output

Improve the download test:
* Allow IPv4 DirPort checks to be turned off.
* Add a timeout to stem's consensus download.
* Actually check for download errors, rather than ignoring them.
* Simplify the timeout and download error checking logic.

Tweak whitelist/blacklist checks to be more robust.
Improve logging, make it warn by default.

Cleanse fallback comments more thoroughly:
* non-printables (yes, ContactInfo can have these)
* // comments (don't rely on newlines to prevent // */ escapes)
teor (Tim Wilson-Brown) 8 years ago
parent
commit
7e1b8ae79c
1 changed files with 201 additions and 88 deletions
  1. 201 88
      scripts/maint/updateFallbackDirs.py

+ 201 - 88
scripts/maint/updateFallbackDirs.py

@@ -1,6 +1,7 @@
 #!/usr/bin/python
 #!/usr/bin/python
 
 
 # Usage: scripts/maint/updateFallbackDirs.py > src/or/fallback_dirs.inc
 # Usage: scripts/maint/updateFallbackDirs.py > src/or/fallback_dirs.inc
+# Needs stem available in your PYTHONPATH, or just ln -s ../stem/stem .
 #
 #
 # Then read the generated list to ensure no-one slipped anything funny into
 # Then read the generated list to ensure no-one slipped anything funny into
 # their name or contactinfo
 # their name or contactinfo
@@ -29,17 +30,28 @@ import dateutil.parser
 from stem.descriptor.remote import DescriptorDownloader
 from stem.descriptor.remote import DescriptorDownloader
 
 
 import logging
 import logging
-logging.basicConfig(level=logging.DEBUG)
+# INFO tells you why each relay was included or excluded
+# WARN tells you about potential misconfigurations
+logging.basicConfig(level=logging.WARNING)
 
 
 ## Top-Level Configuration
 ## Top-Level Configuration
 
 
-# Perform DirPort checks over IPv6?
-# If you know IPv6 works for you, set this to True
-PERFORM_IPV6_DIRPORT_CHECKS = False
-
 # Output all candidate fallbacks, or only output selected fallbacks?
 # Output all candidate fallbacks, or only output selected fallbacks?
 OUTPUT_CANDIDATES = False
 OUTPUT_CANDIDATES = False
 
 
+# Perform DirPort checks over IPv4?
+# Change this to False if IPv4 doesn't work for you, or if you don't want to
+# download a consensus for each fallback
+# Don't check ~1000 candidates when OUTPUT_CANDIDATES is True
+PERFORM_IPV4_DIRPORT_CHECKS = False if OUTPUT_CANDIDATES else True
+
+# Perform DirPort checks over IPv6?
+# If you know IPv6 works for you, set this to True
+# This will exclude IPv6 relays without an IPv6 DirPort configured
+# So it's best left at False until #18394 is implemented
+# Don't check ~1000 candidates when OUTPUT_CANDIDATES is True
+PERFORM_IPV6_DIRPORT_CHECKS = False if OUTPUT_CANDIDATES else False
+
 ## OnionOO Settings
 ## OnionOO Settings
 
 
 ONIONOO = 'https://onionoo.torproject.org/'
 ONIONOO = 'https://onionoo.torproject.org/'
@@ -81,7 +93,7 @@ MAX_LIST_FILE_SIZE = 1024 * 1024
 
 
 # Reduced due to a bug in tor where a relay submits a 0 DirPort when restarted
 # Reduced due to a bug in tor where a relay submits a 0 DirPort when restarted
 # This causes OnionOO to (correctly) reset its stability timer
 # This causes OnionOO to (correctly) reset its stability timer
-# This issue is fixed in 0.2.7.7 and master.
+# This issue will be fixed in 0.2.7.7 and 0.2.8.2
 # Until then, the CUTOFFs below ensure a decent level of stability.
 # Until then, the CUTOFFs below ensure a decent level of stability.
 ADDRESS_AND_PORT_STABLE_DAYS = 7
 ADDRESS_AND_PORT_STABLE_DAYS = 7
 # What time-weighted-fraction of these flags must FallbackDirs
 # What time-weighted-fraction of these flags must FallbackDirs
@@ -157,36 +169,52 @@ def parse_ts(t):
 
 
 def remove_bad_chars(raw_string, bad_char_list):
 def remove_bad_chars(raw_string, bad_char_list):
   # Remove each character in the bad_char_list
   # Remove each character in the bad_char_list
-  escaped_string = raw_string
+  cleansed_string = raw_string
   for c in bad_char_list:
   for c in bad_char_list:
-    escaped_string = escaped_string.replace(c, '')
-  return escaped_string
+    cleansed_string = cleansed_string.replace(c, '')
+  return cleansed_string
+
+def cleanse_unprintable(raw_string):
+  # Remove all unprintable characters
+  cleansed_string = ''
+  for c in raw_string:
+    if (c in string.ascii_letters or c in string.digits
+        or c in string.punctuation or c in string.whitespace):
+      cleansed_string += c
+  return cleansed_string
 
 
 def cleanse_whitespace(raw_string):
 def cleanse_whitespace(raw_string):
   # Replace all whitespace characters with a space
   # Replace all whitespace characters with a space
-  escaped_string = raw_string
+  cleansed_string = raw_string
   for c in string.whitespace:
   for c in string.whitespace:
-    escaped_string = escaped_string.replace(c, ' ')
-  return escaped_string
+    cleansed_string = cleansed_string.replace(c, ' ')
+  return cleansed_string
 
 
 def cleanse_c_multiline_comment(raw_string):
 def cleanse_c_multiline_comment(raw_string):
+  cleansed_string = raw_string
+  # Embedded newlines should be removed by tor/onionoo, but let's be paranoid
+  cleansed_string = cleanse_whitespace(cleansed_string)
+  # ContactInfo and Version can be arbitrary binary data
+  cleansed_string = cleanse_unprintable(cleansed_string)
   # Prevent a malicious / unanticipated string from breaking out
   # Prevent a malicious / unanticipated string from breaking out
   # of a C-style multiline comment
   # of a C-style multiline comment
-  # This removes '/*' and '*/'
-  # To deal with '//', the end comment must be on its own line
-  bad_char_list = '*'
+  # This removes '/*' and '*/' and '//'
+  bad_char_list = '*/'
   # Prevent a malicious string from using C nulls
   # Prevent a malicious string from using C nulls
   bad_char_list += '\0'
   bad_char_list += '\0'
   # Be safer by removing bad characters entirely
   # Be safer by removing bad characters entirely
-  escaped_string = remove_bad_chars(raw_string, bad_char_list)
-  # Embedded newlines should be removed by tor/onionoo, but let's be paranoid
-  escaped_string = cleanse_whitespace(escaped_string)
+  cleansed_string = remove_bad_chars(cleansed_string, bad_char_list)
   # Some compilers may further process the content of comments
   # Some compilers may further process the content of comments
   # There isn't much we can do to cover every possible case
   # There isn't much we can do to cover every possible case
   # But comment-based directives are typically only advisory
   # But comment-based directives are typically only advisory
-  return escaped_string
+  return cleansed_string
 
 
 def cleanse_c_string(raw_string):
 def cleanse_c_string(raw_string):
+  cleansed_string = raw_string
+  # Embedded newlines should be removed by tor/onionoo, but let's be paranoid
+  cleansed_string = cleanse_whitespace(cleansed_string)
+  # ContactInfo and Version can be arbitrary binary data
+  cleansed_string = cleanse_unprintable(cleansed_string)
   # Prevent a malicious address/fingerprint string from breaking out
   # Prevent a malicious address/fingerprint string from breaking out
   # of a C-style string
   # of a C-style string
   bad_char_list = '"'
   bad_char_list = '"'
@@ -195,13 +223,11 @@ def cleanse_c_string(raw_string):
   # Prevent a malicious string from using C nulls
   # Prevent a malicious string from using C nulls
   bad_char_list += '\0'
   bad_char_list += '\0'
   # Be safer by removing bad characters entirely
   # Be safer by removing bad characters entirely
-  escaped_string = remove_bad_chars(raw_string, bad_char_list)
-  # Embedded newlines should be removed by tor/onionoo, but let's be paranoid
-  escaped_string = cleanse_whitespace(escaped_string)
+  cleansed_string = remove_bad_chars(cleansed_string, bad_char_list)
   # Some compilers may further process the content of strings
   # Some compilers may further process the content of strings
   # There isn't much we can do to cover every possible case
   # There isn't much we can do to cover every possible case
   # But this typically only results in changes to the string data
   # But this typically only results in changes to the string data
-  return escaped_string
+  return cleansed_string
 
 
 ## OnionOO Source Functions
 ## OnionOO Source Functions
 
 
@@ -244,11 +270,11 @@ def write_to_file(str, file_name, max_len):
     with open(file_name, 'w') as f:
     with open(file_name, 'w') as f:
       f.write(str[0:max_len])
       f.write(str[0:max_len])
   except EnvironmentError, error:
   except EnvironmentError, error:
-    logging.debug('Writing file %s failed: %d: %s'%
-                  (file_name,
-                   error.errno,
-                   error.strerror)
-                  )
+    logging.warning('Writing file %s failed: %d: %s'%
+                    (file_name,
+                     error.errno,
+                     error.strerror)
+                    )
 
 
 def read_from_file(file_name, max_len):
 def read_from_file(file_name, max_len):
   try:
   try:
@@ -256,11 +282,11 @@ def read_from_file(file_name, max_len):
       with open(file_name, 'r') as f:
       with open(file_name, 'r') as f:
         return f.read(max_len)
         return f.read(max_len)
   except EnvironmentError, error:
   except EnvironmentError, error:
-    logging.debug('Loading file %s failed: %d: %s'%
-                  (file_name,
-                   error.errno,
-                   error.strerror)
-                  )
+    logging.info('Loading file %s failed: %d: %s'%
+                 (file_name,
+                  error.errno,
+                  error.strerror)
+                 )
   return None
   return None
 
 
 def load_possibly_compressed_response_json(response):
 def load_possibly_compressed_response_json(response):
@@ -699,30 +725,37 @@ class Candidate(object):
       self._badexit = self._avg_generic_history(badexit) / ONIONOO_SCALE_ONE
       self._badexit = self._avg_generic_history(badexit) / ONIONOO_SCALE_ONE
 
 
   def is_candidate(self):
   def is_candidate(self):
+    must_be_running_now = (PERFORM_IPV4_DIRPORT_CHECKS
+                           or PERFORM_IPV6_DIRPORT_CHECKS)
+    if (must_be_running_now and not self.is_running()):
+      logging.info('%s not a candidate: not running now, unable to check ' +
+                   'DirPort consensus download', self._fpr)
+      return False
     if (self._data['last_changed_address_or_port'] >
     if (self._data['last_changed_address_or_port'] >
         self.CUTOFF_ADDRESS_AND_PORT_STABLE):
         self.CUTOFF_ADDRESS_AND_PORT_STABLE):
-      logging.debug('%s not a candidate: changed address/port recently (%s)',
-        self._fpr, self._data['last_changed_address_or_port'])
+      logging.info('%s not a candidate: changed address/port recently (%s)',
+                   self._fpr, self._data['last_changed_address_or_port'])
       return False
       return False
     if self._running < CUTOFF_RUNNING:
     if self._running < CUTOFF_RUNNING:
-      logging.debug('%s not a candidate: running avg too low (%lf)',
-                    self._fpr, self._running)
+      logging.info('%s not a candidate: running avg too low (%lf)',
+                   self._fpr, self._running)
       return False
       return False
     if self._v2dir < CUTOFF_V2DIR:
     if self._v2dir < CUTOFF_V2DIR:
-      logging.debug('%s not a candidate: v2dir avg too low (%lf)',
-                    self._fpr, self._v2dir)
+      logging.info('%s not a candidate: v2dir avg too low (%lf)',
+                   self._fpr, self._v2dir)
       return False
       return False
     if self._badexit is not None and self._badexit > PERMITTED_BADEXIT:
     if self._badexit is not None and self._badexit > PERMITTED_BADEXIT:
-      logging.debug('%s not a candidate: badexit avg too high (%lf)',
-                    self._fpr, self._badexit)
+      logging.info('%s not a candidate: badexit avg too high (%lf)',
+                   self._fpr, self._badexit)
       return False
       return False
     # if the relay doesn't report a version, also exclude the relay
     # if the relay doesn't report a version, also exclude the relay
     if (not self._data.has_key('recommended_version')
     if (not self._data.has_key('recommended_version')
         or not self._data['recommended_version']):
         or not self._data['recommended_version']):
+      logging.info('%s not a candidate: version not recommended', self._fpr)
       return False
       return False
     if self._guard < CUTOFF_GUARD:
     if self._guard < CUTOFF_GUARD:
-      logging.debug('%s not a candidate: guard avg too low (%lf)',
-                    self._fpr, self._guard)
+      logging.info('%s not a candidate: guard avg too low (%lf)',
+                   self._fpr, self._guard)
       return False
       return False
     return True
     return True
 
 
@@ -736,24 +769,48 @@ class Candidate(object):
         If the fallback has an ipv6 key, the whitelist line must also have
         If the fallback has an ipv6 key, the whitelist line must also have
         it, and vice versa, otherwise they don't match. """
         it, and vice versa, otherwise they don't match. """
     for entry in relaylist:
     for entry in relaylist:
+      if  entry['id'] != self._fpr:
+        # can't log here, every relay's fingerprint is compared to the entry
+        continue
       if entry['ipv4'] != self.dirip:
       if entry['ipv4'] != self.dirip:
+        logging.info('%s is not in the whitelist: fingerprint matches, but ' +
+                     'IPv4 (%s) does not match entry IPv4 (%s)',
+                     self._fpr, self.dirip, entry['ipv4'])
         continue
         continue
       if int(entry['dirport']) != self.dirport:
       if int(entry['dirport']) != self.dirport:
+        logging.info('%s is not in the whitelist: fingerprint matches, but ' +
+                     'DirPort (%d) does not match entry DirPort (%d)',
+                     self._fpr, self.dirport, int(entry['dirport']))
         continue
         continue
       if int(entry['orport']) != self.orport:
       if int(entry['orport']) != self.orport:
+        logging.info('%s is not in the whitelist: fingerprint matches, but ' +
+                     'ORPort (%d) does not match entry ORPort (%d)',
+                     self._fpr, self.orport, int(entry['orport']))
         continue
         continue
-      if  entry['id'] != self._fpr:
-        continue
-      if (entry.has_key('ipv6')
-          and self.ipv6addr is not None and self.ipv6orport is not None):
+      has_ipv6 = self.ipv6addr is not None and self.ipv6orport is not None
+      if (entry.has_key('ipv6') and has_ipv6):
+        ipv6 = self.ipv6addr + ':' + self.ipv6orport
         # if both entry and fallback have an ipv6 address, compare them
         # if both entry and fallback have an ipv6 address, compare them
-        if entry['ipv6'] != self.ipv6addr + ':' + self.ipv6orport:
+        if entry['ipv6'] != ipv6:
+          logging.info('%s is not in the whitelist: fingerprint matches, ' +
+                       'but IPv6 (%s) does not match entry IPv6 (%s)',
+                       self._fpr, ipv6, entry['ipv6'])
           continue
           continue
       # if the fallback has an IPv6 address but the whitelist entry
       # if the fallback has an IPv6 address but the whitelist entry
       # doesn't, or vice versa, the whitelist entry doesn't match
       # doesn't, or vice versa, the whitelist entry doesn't match
-      elif entry.has_key('ipv6') and self.ipv6addr is None:
+      elif entry.has_key('ipv6') and not has_ipv6:
+        logging.info('%s is not in the whitelist: fingerprint matches, but ' +
+                     'it has no IPv6, and entry has IPv6 (%s)', self._fpr,
+                     entry['ipv6'])
+        logging.warning('%s excluded: has it lost its former IPv6 address %s?',
+                        self._fpr, entry['ipv6'])
         continue
         continue
-      elif not entry.has_key('ipv6') and self.ipv6addr is not None:
+      elif not entry.has_key('ipv6') and has_ipv6:
+        logging.info('%s is not in the whitelist: fingerprint matches, but ' +
+                     'it has IPv6 (%s), and entry has no IPv6', self._fpr,
+                     ipv6)
+        logging.warning('%s excluded: has it gained an IPv6 address %s?',
+                        self._fpr, ipv6)
         continue
         continue
       return True
       return True
     return False
     return False
@@ -773,34 +830,60 @@ class Candidate(object):
     for entry in relaylist:
     for entry in relaylist:
       for key in entry:
       for key in entry:
         value = entry[key]
         value = entry[key]
+        if key == 'id' and value == self._fpr:
+          logging.info('%s is in the blacklist: fingerprint matches',
+                       self._fpr)
+          return True
         if key == 'ipv4' and value == self.dirip:
         if key == 'ipv4' and value == self.dirip:
           # if the dirport is present, check it too
           # if the dirport is present, check it too
           if entry.has_key('dirport'):
           if entry.has_key('dirport'):
             if int(entry['dirport']) == self.dirport:
             if int(entry['dirport']) == self.dirport:
+              logging.info('%s is in the blacklist: IPv4 (%s) and ' +
+                           'DirPort (%d) match', self._fpr, self.dirip,
+                           self.dirport)
               return True
               return True
           # if the orport is present, check it too
           # if the orport is present, check it too
           elif entry.has_key('orport'):
           elif entry.has_key('orport'):
             if int(entry['orport']) == self.orport:
             if int(entry['orport']) == self.orport:
+              logging.info('%s is in the blacklist: IPv4 (%s) and ' +
+                           'ORPort (%d) match', self._fpr, self.dirip,
+                           self.orport)
               return True
               return True
           else:
           else:
+            logging.info('%s is in the blacklist: IPv4 (%s) matches, and ' +
+                         'entry has no DirPort or ORPort', self._fpr,
+                         self.dirip)
             return True
             return True
-        if key == 'id' and value == self._fpr:
-          return True
-        if (key == 'ipv6'
-            and self.ipv6addr is not None and self.ipv6orport is not None):
+        has_ipv6 = self.ipv6addr is not None and self.ipv6orport is not None
+        ipv6 = (self.ipv6addr + ':' + self.ipv6orport) if has_ipv6 else None
+        if (key == 'ipv6' and has_ipv6):
         # if both entry and fallback have an ipv6 address, compare them,
         # if both entry and fallback have an ipv6 address, compare them,
         # otherwise, disregard ipv6 addresses
         # otherwise, disregard ipv6 addresses
-          if value == self.ipv6addr + ':' + self.ipv6orport:
+          if value == ipv6:
             # if the dirport is present, check it too
             # if the dirport is present, check it too
             if entry.has_key('dirport'):
             if entry.has_key('dirport'):
               if int(entry['dirport']) == self.dirport:
               if int(entry['dirport']) == self.dirport:
+                logging.info('%s is in the blacklist: IPv6 (%s) and ' +
+                             'DirPort (%d) match', self._fpr, ipv6,
+                             self.dirport)
                 return True
                 return True
-            # if the orport is present, check it too
-            elif entry.has_key('orport'):
-              if int(entry['orport']) == self.orport:
-                return True
+            # we've already checked the ORPort, it's part of entry['ipv6']
             else:
             else:
+              logging.info('%s is in the blacklist: IPv6 (%s) matches, and' +
+                           'entry has no DirPort', self._fpr, ipv6)
               return True
               return True
+        elif (key == 'ipv6' or has_ipv6):
+          # only log if the fingerprint matches but the IPv6 doesn't
+          if entry.has_key('id') and entry['id'] == self._fpr:
+            logging.info('%s skipping IPv6 blacklist comparison: relay ' +
+                         'has%s IPv6%s, but entry has%s IPv6%s', self._fpr,
+                         '' if has_ipv6 else ' no',
+                         (' (' + ipv6 + ')') if has_ipv6 else  '',
+                         '' if key == 'ipv6' else ' no',
+                         (' (' + value + ')') if key == 'ipv6' else '')
+            logging.warning('Has %s %s IPv6 address %s?', self._fpr,
+                            'gained an' if has_ipv6 else 'lost its former',
+                            ipv6 if has_ipv6 else value)
     return False
     return False
 
 
   def is_exit(self):
   def is_exit(self):
@@ -809,6 +892,9 @@ class Candidate(object):
   def is_guard(self):
   def is_guard(self):
     return 'Guard' in self._data['flags']
     return 'Guard' in self._data['flags']
 
 
+  def is_running(self):
+    return 'Running' in self._data['flags']
+
   def fallback_weight_fraction(self, total_weight):
   def fallback_weight_fraction(self, total_weight):
     return float(self._data['consensus_weight']) / total_weight
     return float(self._data['consensus_weight']) / total_weight
 
 
@@ -825,53 +911,70 @@ class Candidate(object):
 
 
   @staticmethod
   @staticmethod
   def fallback_consensus_dl_speed(dirip, dirport, nickname, max_time):
   def fallback_consensus_dl_speed(dirip, dirport, nickname, max_time):
+    download_failed = False
     downloader = DescriptorDownloader()
     downloader = DescriptorDownloader()
     start = datetime.datetime.utcnow()
     start = datetime.datetime.utcnow()
+    # some directory mirrors respond to requests in ways that hang python
+    # sockets, which is why we long this line here
+    logging.info('Initiating consensus download from %s (%s:%d).', nickname,
+                 dirip, dirport)
     # there appears to be about 1 second of overhead when comparing stem's
     # there appears to be about 1 second of overhead when comparing stem's
     # internal trace time and the elapsed time calculated here
     # internal trace time and the elapsed time calculated here
-    downloader.get_consensus(endpoints = [(dirip, dirport)]).run()
+    TIMEOUT_SLOP = 1.0
+    try:
+      downloader.get_consensus(endpoints = [(dirip, dirport)],
+                               timeout = (max_time + TIMEOUT_SLOP),
+                               validate = True,
+                               retries = 0,
+                               fall_back_to_authority = False).run()
+    except Exception, stem_error:
+      logging.debug('Unable to retrieve a consensus from %s: %s', nickname,
+                    stem_error)
+      status = 'error: "%s"' % (stem_error)
+      level = logging.WARNING
+      download_failed = True
     elapsed = (datetime.datetime.utcnow() - start).total_seconds()
     elapsed = (datetime.datetime.utcnow() - start).total_seconds()
     if elapsed > max_time:
     if elapsed > max_time:
       status = 'too slow'
       status = 'too slow'
+      level = logging.WARNING
+      download_failed = True
     else:
     else:
       status = 'ok'
       status = 'ok'
-    logging.debug(('Consensus download: %0.2fs %s from %s (%s:%d), '
-                   + 'max download time %0.2fs.') % (elapsed, status,
-                                                     nickname, dirip, dirport,
-                                                     max_time))
-    return elapsed
+      level = logging.DEBUG
+    logging.log(level, 'Consensus download: %0.1fs %s from %s (%s:%d), ' +
+                 'max download time %0.1fs.', elapsed, status, nickname,
+                 dirip, dirport, max_time)
+    return download_failed
 
 
   def fallback_consensus_dl_check(self):
   def fallback_consensus_dl_check(self):
-    ipv4_speed = Candidate.fallback_consensus_dl_speed(self.dirip,
+    # include the relay if we're not doing a check, or we can't check (IPv6)
+    ipv4_failed = False
+    ipv6_failed = False
+    if PERFORM_IPV4_DIRPORT_CHECKS:
+      ipv4_failed = Candidate.fallback_consensus_dl_speed(self.dirip,
                                                 self.dirport,
                                                 self.dirport,
                                                 self._data['nickname'],
                                                 self._data['nickname'],
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
     if self.ipv6addr is not None and PERFORM_IPV6_DIRPORT_CHECKS:
     if self.ipv6addr is not None and PERFORM_IPV6_DIRPORT_CHECKS:
       # Clients assume the IPv6 DirPort is the same as the IPv4 DirPort
       # Clients assume the IPv6 DirPort is the same as the IPv4 DirPort
-      ipv6_speed = Candidate.fallback_consensus_dl_speed(self.ipv6addr,
+      ipv6_failed = Candidate.fallback_consensus_dl_speed(self.ipv6addr,
                                                 self.dirport,
                                                 self.dirport,
                                                 self._data['nickname'],
                                                 self._data['nickname'],
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
-    else:
-      ipv6_speed = None
     # Now retry the relay if it took too long the first time
     # Now retry the relay if it took too long the first time
-    if (ipv4_speed > CONSENSUS_DOWNLOAD_SPEED_MAX
+    if (PERFORM_IPV4_DIRPORT_CHECKS and ipv4_failed
         and CONSENSUS_DOWNLOAD_RETRY):
         and CONSENSUS_DOWNLOAD_RETRY):
-      ipv4_speed = Candidate.fallback_consensus_dl_speed(self.dirip,
+      ipv4_failed = Candidate.fallback_consensus_dl_speed(self.dirip,
                                                 self.dirport,
                                                 self.dirport,
                                                 self._data['nickname'],
                                                 self._data['nickname'],
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
     if (self.ipv6addr is not None and PERFORM_IPV6_DIRPORT_CHECKS
     if (self.ipv6addr is not None and PERFORM_IPV6_DIRPORT_CHECKS
-        and ipv6_speed > CONSENSUS_DOWNLOAD_SPEED_MAX
-        and CONSENSUS_DOWNLOAD_RETRY):
-      ipv6_speed = Candidate.fallback_consensus_dl_speed(self.ipv6addr,
+        and ipv6_failed and CONSENSUS_DOWNLOAD_RETRY):
+      ipv6_failed = Candidate.fallback_consensus_dl_speed(self.ipv6addr,
                                                 self.dirport,
                                                 self.dirport,
                                                 self._data['nickname'],
                                                 self._data['nickname'],
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
                                                 CONSENSUS_DOWNLOAD_SPEED_MAX)
-
-    return (ipv4_speed <= CONSENSUS_DOWNLOAD_SPEED_MAX
-            and (not PERFORM_IPV6_DIRPORT_CHECKS
-                 or ipv6_speed <= CONSENSUS_DOWNLOAD_SPEED_MAX))
+    return ((not ipv4_failed) and (not ipv6_failed))
 
 
   def fallbackdir_line(self, total_weight, original_total_weight, dl_speed_ok):
   def fallbackdir_line(self, total_weight, original_total_weight, dl_speed_ok):
     # /*
     # /*
@@ -1071,8 +1174,8 @@ class CandidateList(dict):
         if BLACKLIST_EXCLUDES_WHITELIST_ENTRIES:
         if BLACKLIST_EXCLUDES_WHITELIST_ENTRIES:
           # exclude
           # exclude
           excluded_count += 1
           excluded_count += 1
-          logging.debug('Excluding %s: in both blacklist and whitelist.' %
-                        f._fpr)
+          logging.warning('Excluding %s: in both blacklist and whitelist.',
+                          f._fpr)
         else:
         else:
           # include
           # include
           filtered_fallbacks.append(f)
           filtered_fallbacks.append(f)
@@ -1082,8 +1185,7 @@ class CandidateList(dict):
       elif in_blacklist:
       elif in_blacklist:
         # exclude
         # exclude
         excluded_count += 1
         excluded_count += 1
-        logging.debug('Excluding %s: in blacklist.' %
-                      f._fpr)
+        logging.debug('Excluding %s: in blacklist.', f._fpr)
       else:
       else:
         if INCLUDE_UNLISTED_ENTRIES:
         if INCLUDE_UNLISTED_ENTRIES:
           # include
           # include
@@ -1091,8 +1193,8 @@ class CandidateList(dict):
         else:
         else:
           # exclude
           # exclude
           excluded_count += 1
           excluded_count += 1
-          logging.debug('Excluding %s: in neither blacklist nor whitelist.' %
-                        f._fpr)
+          logging.info('Excluding %s: in neither blacklist nor whitelist.',
+                       f._fpr)
     self.fallbacks = filtered_fallbacks
     self.fallbacks = filtered_fallbacks
     return excluded_count
     return excluded_count
 
 
@@ -1173,15 +1275,14 @@ class CandidateList(dict):
     # Integers don't need escaping in C comments
     # Integers don't need escaping in C comments
     fallback_count = len(self.fallbacks)
     fallback_count = len(self.fallbacks)
     if FALLBACK_PROPORTION_OF_GUARDS is None:
     if FALLBACK_PROPORTION_OF_GUARDS is None:
-      fallback_proportion = ''
+      fallback_proportion = ' (none)'
     else:
     else:
-      fallback_proportion = ' (%d * %f)'%(guard_count,
+      fallback_proportion = '%d (%d * %f)'%(target_count, guard_count,
                                           FALLBACK_PROPORTION_OF_GUARDS)
                                           FALLBACK_PROPORTION_OF_GUARDS)
     s += 'Final Count:  %d (Eligible %d, Usable %d, Target %d%s'%(
     s += 'Final Count:  %d (Eligible %d, Usable %d, Target %d%s'%(
             min(max_count, fallback_count),
             min(max_count, fallback_count),
             eligible_count,
             eligible_count,
             fallback_count,
             fallback_count,
-            target_count,
             fallback_proportion)
             fallback_proportion)
     if MAX_FALLBACK_COUNT is not None:
     if MAX_FALLBACK_COUNT is not None:
       s += ', Clamped to %d'%(MAX_FALLBACK_COUNT)
       s += ', Clamped to %d'%(MAX_FALLBACK_COUNT)
@@ -1242,6 +1343,16 @@ class CandidateList(dict):
         s += '#error ' + error_str
         s += '#error ' + error_str
       else:
       else:
         s += '/* ' + error_str + ' */'
         s += '/* ' + error_str + ' */'
+    s += '\n'
+    if PERFORM_IPV4_DIRPORT_CHECKS or PERFORM_IPV6_DIRPORT_CHECKS:
+      s += '/* Checked %s%s%s DirPorts served a consensus within %.1fs. */'%(
+            'IPv4' if PERFORM_IPV4_DIRPORT_CHECKS else '',
+            ' and ' if (PERFORM_IPV4_DIRPORT_CHECKS
+                        and PERFORM_IPV6_DIRPORT_CHECKS) else '',
+            'IPv6' if PERFORM_IPV6_DIRPORT_CHECKS else '',
+            CONSENSUS_DOWNLOAD_SPEED_MAX)
+    else:
+      s += '/* Did not check IPv4 or IPv6 DirPort consensus downloads. */'
     return s
     return s
 
 
 ## Main Function
 ## Main Function
@@ -1250,9 +1361,11 @@ def list_fallbacks():
   """ Fetches required onionoo documents and evaluates the
   """ Fetches required onionoo documents and evaluates the
       fallback directory criteria for each of the relays """
       fallback directory criteria for each of the relays """
 
 
+  # find relays that could be fallbacks
   candidates = CandidateList()
   candidates = CandidateList()
   candidates.add_relays()
   candidates.add_relays()
 
 
+  # work out how many fallbacks we want
   guard_count = candidates.count_guards()
   guard_count = candidates.count_guards()
   if FALLBACK_PROPORTION_OF_GUARDS is None:
   if FALLBACK_PROPORTION_OF_GUARDS is None:
     target_count = guard_count
     target_count = guard_count
@@ -1268,10 +1381,10 @@ def list_fallbacks():
 
 
   candidates.compute_fallbacks()
   candidates.compute_fallbacks()
 
 
+  # filter with the whitelist and blacklist
   initial_count = len(candidates.fallbacks)
   initial_count = len(candidates.fallbacks)
   excluded_count = candidates.apply_filter_lists()
   excluded_count = candidates.apply_filter_lists()
   print candidates.summarise_filters(initial_count, excluded_count)
   print candidates.summarise_filters(initial_count, excluded_count)
-
   eligible_count = len(candidates.fallbacks)
   eligible_count = len(candidates.fallbacks)
   eligible_weight = candidates.fallback_weight_total()
   eligible_weight = candidates.fallback_weight_total()