chiark / gitweb /
peerdb/tripe-newpeers.in: Split out a class for a host's resolved names.
[tripe] / peerdb / tripe-newpeers.in
index d22aff7f94adbd96c9b1948a87fba64b95dc4b89..502492d9673b904c2c990a5536a59a4050d0cba6 100644 (file)
 ###
 ### This file is part of Trivial IP Encryption (TrIPE).
 ###
 ###
 ### This file is part of Trivial IP Encryption (TrIPE).
 ###
-### TrIPE 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 2 of the License, or
-### (at your option) any later version.
+### TrIPE 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.
 ###
 ###
-### TrIPE 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.
+### TrIPE 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
 ###
 ### You should have received a copy of the GNU General Public License
-### along with TrIPE; if not, write to the Free Software Foundation,
-### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+### along with TrIPE.  If not, see <https://www.gnu.org/licenses/>.
 
 VERSION = '@VERSION@'
 
 ###--------------------------------------------------------------------------
 ### External dependencies.
 
 
 VERSION = '@VERSION@'
 
 ###--------------------------------------------------------------------------
 ### External dependencies.
 
-import ConfigParser as CP
 import mLib as M
 from optparse import OptionParser
 import cdb as CDB
 from sys import stdin, stdout, exit, argv
 import re as RX
 import os as OS
 import mLib as M
 from optparse import OptionParser
 import cdb as CDB
 from sys import stdin, stdout, exit, argv
 import re as RX
 import os as OS
+from cStringIO import StringIO
 
 ###--------------------------------------------------------------------------
 ### Utilities.
 
 ###--------------------------------------------------------------------------
 ### Utilities.
@@ -49,9 +48,44 @@ class CDBFake (object):
   def finish(me):
     pass
 
   def finish(me):
     pass
 
+class ExpectedError (Exception): pass
+
 ###--------------------------------------------------------------------------
 ### A bulk DNS resolver.
 
 ###--------------------------------------------------------------------------
 ### A bulk DNS resolver.
 
+class ResolverFailure (ExpectedError):
+  def __init__(me, host, msg):
+    me.host = host
+    me.msg = msg
+  def __str__(me):
+    return "failed to resolve `%s': %s" % (me.host, me.msg)
+
+class ResolvingHost (object):
+  """
+  A host name which is being looked up by a bulk-resolver instance.
+  """
+
+  def __init__(me, name):
+    """Make a new resolving-host object for the host NAME."""
+    me.name = name
+    me.addr = None
+    me.failure = None
+
+  def setaddr(me, addr):
+    """Add the address ADDR."""
+    me.addr = addr
+
+  def failed(me, msg):
+    """
+    Report that resolution of this host failed, with a human-readable MSG.
+    """
+    me.failure = msg
+
+  def get(me):
+    """Return the resolved address."""
+    if me.failure is not None: raise ResolverFailure(me.name, me.failure)
+    return me.addr
+
 class BulkResolver (object):
   """
   Resolve a number of DNS names in parallel.
 class BulkResolver (object):
   """
   Resolve a number of DNS names in parallel.
@@ -70,51 +104,271 @@ class BulkResolver (object):
 
   def __init__(me):
     """Initialize the resolver."""
 
   def __init__(me):
     """Initialize the resolver."""
-    me._resolvers = {}
     me._namemap = {}
     me._namemap = {}
-
-  def prepare(me, host):
-    """Prime the resolver to resolve the name HOST."""
-    if host not in me._resolvers:
-      me._resolvers[host] = M.SelResolveByName \
-                            (host,
-                             lambda name, alias, addr:
-                               me._resolved(host, addr[0]),
-                             lambda: me._resolved(host, None))
+    me._noutstand = 0
+
+  def prepare(me, name):
+    """Prime the resolver to resolve the given host NAME."""
+    if name not in me._namemap:
+      me._namemap[name] = host = ResolvingHost(name)
+      host._resolv = M.SelResolveByName(
+        name,
+        lambda cname, alias, addr: me._resolved(host, addr[0]),
+        lambda: me._resolved(host, None))
+      me._noutstand += 1
 
   def run(me):
     """Run the background DNS resolver until it's finished."""
 
   def run(me):
     """Run the background DNS resolver until it's finished."""
-    while me._resolvers:
-      M.select()
+    while me._noutstand: M.select()
 
 
-  def lookup(me, host):
-    """
-    Fetch the address corresponding to HOST.
-    """
-    addr = me._namemap[host]
-    if addr is None:
-      raise KeyError, host
-    return addr
+  def lookup(me, name):
+    """Fetch the address corresponding to the host NAME."""
+    return me._namemap[name].get()
 
   def _resolved(me, host, addr):
     """Callback function: remember that ADDR is the address for HOST."""
 
   def _resolved(me, host, addr):
     """Callback function: remember that ADDR is the address for HOST."""
-    me._namemap[host] = addr
-    del me._resolvers[host]
+    if addr is None:
+      host.failed('(unknown failure)')
+    else:
+      host.setaddr(addr)
+    host._resolv = None
+    me._noutstand -= 1
 
 ###--------------------------------------------------------------------------
 ### The configuration parser.
 
 
 ###--------------------------------------------------------------------------
 ### The configuration parser.
 
+## Match a comment or empty line.
+RX_COMMENT = RX.compile(r'(?x) ^ \s* (?: $ | [;#])')
+
+## Match a section group header.
+RX_GRPHDR = RX.compile(r'(?x) ^ \s* \[ (.*) \] \s* $')
+
+## Match an assignment line.
+RX_ASSGN = RX.compile(r'''(?x) ^
+        ([^\s:=] (?: [^:=]* [^\s:=])?)
+        \s* [:=] \s*
+        (| \S | \S.*\S)
+        \s* $''')
+
+## Match a continuation line.
+RX_CONT = RX.compile(r'''(?x) ^ \s+
+        (| \S | \S.*\S)
+        \s* $''')
+
 ## Match a $(VAR) configuration variable reference; group 1 is the VAR.
 ## Match a $(VAR) configuration variable reference; group 1 is the VAR.
-r_ref = RX.compile(r'\$\(([^)]+)\)')
+RX_REF = RX.compile(r'(?x) \$ \( ([^)]+) \)')
 
 ## Match a $[HOST] name resolution reference; group 1 is the HOST.
 
 ## Match a $[HOST] name resolution reference; group 1 is the HOST.
-r_resolve = RX.compile(r'\$\[([^]]+)\]')
+RX_RESOLVE = RX.compile(r'(?x) \$ \[ ([^]]+) \]')
+
+class ConfigSyntaxError (ExpectedError):
+  def __init__(me, fname, lno, msg):
+    me.fname = fname
+    me.lno = lno
+    me.msg = msg
+  def __str__(me):
+    return '%s:%d: %s' % (me.fname, me.lno, me.msg)
+
+def _fmt_path(path):
+  return ' -> '.join(["`%s'" % hop for hop in path])
+
+class AmbiguousOptionError (ExpectedError):
+  def __init__(me, key, patha, vala, pathb, valb):
+    me.key = key
+    me.patha, me.vala = patha, vala
+    me.pathb, me.valb = pathb, valb
+  def __str__(me):
+    return "Ambiguous answer resolving key `%s': " \
+        "path %s yields `%s' but %s yields `%s'" % \
+        (me.key, _fmt_path(me.patha), me.vala, _fmt_path(me.pathb), me.valb)
+
+class InheritanceCycleError (ExpectedError):
+  def __init__(me, key, path):
+    me.key = key
+    me.path = path
+  def __str__(me):
+    return "Found a cycle %s looking up key `%s'" % \
+        (_fmt_path(me.path), me.key)
+
+class MissingSectionException (ExpectedError):
+  def __init__(me, sec):
+    me.sec = sec
+  def __str__(me):
+    return "Section `%s' not found" % (me.sec)
+
+class MissingKeyException (ExpectedError):
+  def __init__(me, sec, key):
+    me.sec = sec
+    me.key = key
+  def __str__(me):
+    return "Key `%s' not found in section `%s'" % (me.key, me.sec)
+
+class ConfigSection (object):
+  """
+  A section in a configuration parser.
+
+  This is where a lot of the nitty-gritty stuff actually happens.  The
+  `MyConfigParser' knows a lot about the internals of this class, which saves
+  on building a complicated interface.
+  """
+
+  def __init__(me, name, cp):
+    """Initialize a new, empty section with a given NAME and parent CP."""
+
+    ## The cache maps item keys to entries, which consist of a pair of
+    ## objects.  There are four possible states for a cache entry:
+    ##
+    ##   * missing -- there is no entry at all with this key, so we must
+    ##     search for it;
+    ##
+    ##   * None, None -- we are actively trying to resolve this key, so if we
+    ##     encounter this state, we have found a cycle in the inheritance
+    ##     graph;
+    ##
+    ##   * None, [] -- we know that this key isn't reachable through any of
+    ##     our parents;
+    ##
+    ##   * VALUE, PATH -- we know that the key resolves to VALUE, along the
+    ##     PATH from us (exclusive) to the defining parent (inclusive).
+    me.name = name
+    me._itemmap = dict()
+    me._cache = dict()
+    me._cp = cp
+
+  def _expand(me, string, resolvep):
+    """
+    Expands $(...) and (optionally) $[...] placeholders in STRING.
+
+    RESOLVEP is a boolean switch: do we bother to tax the resolver or not?
+    This is turned off by MyConfigParser's resolve() method while it's
+    collecting hostnames to be resolved.
+    """
+    string = RX_REF.sub \
+             (lambda m: me.get(m.group(1), resolvep), string)
+    if resolvep:
+      string = RX_RESOLVE.sub(lambda m: me._cp._resolver.lookup(m.group(1)),
+                              string)
+    return string
+
+  def _parents(me):
+    """Yield this section's parents."""
+    try: names = me._itemmap['@inherit']
+    except KeyError: return
+    for name in names.replace(',', ' ').split():
+      yield me._cp.section(name)
+
+  def _get(me, key, path = None):
+    """
+    Low-level option-fetching method.
+
+    Fetch the value for the named KEY in this section, or maybe (recursively)
+    a section which it inherits from.
+
+    Returns a pair VALUE, PATH.  The value is not expanded; nor do we check
+    for the special `name' key.  The caller is expected to do these things.
+    Returns None if no value could be found.
+    """
+
+    ## If we weren't given a path, then we'd better make one.
+    if path is None: path = []
+
+    ## Extend the path to cover us, but remember to remove us again when
+    ## we've finished.  If we need to pass the current path back upwards,
+    ## then remember to take a copy.
+    path.append(me.name)
+    try:
+
+      ## If we've been this way before on another pass through then return the
+      ## value we found then.  If we're still thinking about it then we've
+      ## found a cycle.
+      try: v, p = me._cache[key]
+      except KeyError: pass
+      else:
+        if p is None: raise InheritanceCycleError(key, path[:])
+        else: return v, path + p
+
+      ## See whether the answer is ready waiting for us.
+      try: v = me._itemmap[key]
+      except KeyError: pass
+      else:
+        p = path[:]
+        me._cache[key] = v, []
+        return v, p
+
+      ## Initially we have no idea.
+      value = None
+      winner = []
+
+      ## Go through our parents and ask them what they think.
+      me._cache[key] = None, None
+      for p in me._parents():
+
+        ## See whether we get an answer.  If not, keep on going.
+        v, pp = p._get(key, path)
+        if v is None: continue
+
+        ## If we got an answer, check that it matches any previous ones.
+        if value is None:
+          value = v
+          winner = pp
+        elif value != v:
+          raise AmbiguousOptionError(key, winner, value, pp, v)
+
+      ## That's the best we could manage.
+      me._cache[key] = value, winner[len(path):]
+      return value, winner
+
+    finally:
+      ## Remove us from the path again.
+      path.pop()
+
+  def get(me, key, resolvep = True):
+    """
+    Retrieve the value of KEY from this section.
+    """
 
 
-class MyConfigParser (CP.RawConfigParser):
+    ## Special handling for the `name' key.
+    if key == 'name':
+      value = me._itemmap.get('name', me.name)
+    elif key == '@inherits':
+      try: return me._itemmap['@inherits']
+      except KeyError: raise MissingKeyException(me.name, key)
+    else:
+      value, _ = me._get(key)
+      if value is None:
+        raise MissingKeyException(me.name, key)
+
+    ## Expand the value and return it.
+    return me._expand(value, resolvep)
+
+  def items(me, resolvep = True):
+    """
+    Yield a list of item names in the section.
+    """
+
+    ## Initialize for a depth-first walk of the inheritance graph.
+    seen = { 'name': True }
+    visiting = { me.name: True }
+    stack = [me]
+
+    ## Visit nodes, collecting their keys.  Don't believe the values:
+    ## resolving inheritance is too hard to do like this.
+    while stack:
+      sec = stack.pop()
+      for p in sec._parents():
+        if p.name not in visiting:
+          stack.append(p); visiting[p.name] = True
+
+      for key in sec._itemmap.iterkeys(): seen[key] = None
+
+    ## And we're done.
+    return seen.iterkeys()
+
+class MyConfigParser (object):
   """
   A more advanced configuration parser.
 
   """
   A more advanced configuration parser.
 
-  This has two major enhancements over the standard ConfigParser which are
+  This has four major enhancements over the standard ConfigParser which are
   relevant to us.
 
     * It recognizes `@inherits' keys and follows them when expanding a
   relevant to us.
 
     * It recognizes `@inherits' keys and follows them when expanding a
@@ -126,15 +380,19 @@ class MyConfigParser (CP.RawConfigParser):
     * It recognizes `$[HOST]' name-resolver requests and handles them
       correctly.
 
     * It recognizes `$[HOST]' name-resolver requests and handles them
       correctly.
 
+    * Its parsing behaviour is well-defined.
+
   Use:
 
   Use:
 
-    1. Call read(FILENAME) and/or read(FP, [FILENAME]) to slurp in the
-       configuration data.
+    1. Call parse(FILENAME) to slurp in the configuration data.
 
     2. Call resolve() to collect the hostnames which need to be resolved and
        actually do the name resolution.
 
 
     2. Call resolve() to collect the hostnames which need to be resolved and
        actually do the name resolution.
 
-    3. Call get(SECTION, ITEM) to collect the results, or items(SECTION) to
+    3. Call sections() to get a list of the configuration sections, or
+       section(NAME) to find a named section.
+
+    4. Call get(ITEM) on a section to collect the results, or items() to
        iterate over them.
   """
 
        iterate over them.
   """
 
@@ -142,100 +400,99 @@ class MyConfigParser (CP.RawConfigParser):
     """
     Initialize a new, empty configuration parser.
     """
     """
     Initialize a new, empty configuration parser.
     """
-    CP.RawConfigParser.__init__(me)
+    me._sectmap = dict()
     me._resolver = BulkResolver()
 
     me._resolver = BulkResolver()
 
-  def resolve(me):
+  def parse(me, f):
     """
     """
-    Works out all of the hostnames which need resolving and resolves them.
-
-    Until you call this, attempts to fetch configuration items which need to
-    resolve hostnames will fail!
+    Parse configuration from a file F.
     """
     """
-    for sec in me.sections():
-      for key, value in me.items(sec, resolvep = False):
-        for match in r_resolve.finditer(value):
-          me._resolver.prepare(match.group(1))
-    me._resolver.run()
 
 
-  def _expand(me, sec, string, resolvep):
-    """
-    Expands $(...) and (optionally) $[...] placeholders in STRING.
+    ## Initial parser state.
+    sect = None
+    key = None
+    val = None
+    lno = 0
+
+    ## An unpleasant hack.  Python makes it hard to capture a value in a
+    ## variable and examine it in a single action, and this is the best that
+    ## I came up with.
+    m = [None]
+    def match(rx): m[0] = rx.match(line); return m[0]
+
+    ## Commit a key's value when we've determined that there are no further
+    ## continuation lines.
+    def flush():
+      if key is not None: sect._itemmap[key] = val.getvalue()
+
+    ## Work through all of the input lines.
+    for line in f:
+      lno += 1
+
+      if match(RX_COMMENT):
+        ## A comment or a blank line.  Nothing doing.  (This means that we
+        ## leave out blank lines which look like they might be continuation
+        ## lines.)
+
+        pass
+
+      elif match(RX_GRPHDR):
+        ## A section header.  Flush out any previous value and set up the new
+        ## group.
+
+        flush()
+        name = m[0].group(1)
+        try: sect = me._sectmap[name]
+        except KeyError: sect = me._sectmap[name] = ConfigSection(name, me)
+        key = None
+
+      elif match(RX_ASSGN):
+        ## A new assignment.  Flush out the old one, and set up to store this
+        ## one.
+
+        if sect is None:
+          raise ConfigSyntaxError(f.name, lno, 'no active section to update')
+        flush()
+        key = m[0].group(1)
+        val = StringIO(); val.write(m[0].group(2))
+
+      elif match(RX_CONT):
+        ## A continuation line.  Accumulate the value.
+
+        if key is None:
+          raise ConfigSyntaxError(f.name, lno, 'no config value to continue')
+        val.write('\n'); val.write(m[0].group(1))
 
 
-    The SEC is the configuration section from which to satisfy $(...)
-    requests.  RESOLVEP is a boolean switch: do we bother to tax the resolver
-    or not?  This is turned off by the resolve() method while it's collecting
-    hostnames to be resolved.
-    """
-    string = r_ref.sub \
-             (lambda m: me.get(sec, m.group(1), resolvep), string)
-    if resolvep:
-      string = r_resolve.sub(lambda m: me._resolver.lookup(m.group(1)),
-                             string)
-    return string
-
-  def has_option(me, sec, key):
-    """
-    Decide whether section SEC has a configuration key KEY.
-
-    This version of the method properly handles the @inherit key.
-    """
-    return CP.RawConfigParser.has_option(me, sec, key) or \
-           (CP.RawConfigParser.has_option(me, sec, '@inherit') and
-            me.has_option(CP.RawConfigParser.get(me, sec, '@inherit'), key))
-
-  def _get(me, basesec, sec, key, resolvep):
-    """
-    Low-level option-fetching method.
+      else:
+        ## Something else.
 
 
-    Fetch the value for the named KEY from section SEC, or maybe
-    (recursively) the section which SEC inherits from.
+        raise ConfigSyntaxError(f.name, lno, 'incomprehensible line')
 
 
-    The result is expanded, by _expend; RESOLVEP is passed to _expand to
-    control whether $[...] should be expanded in the result.
+    ## Don't forget to commit any final value material.
+    flush()
 
 
-    The BASESEC is the section for which the original request was made.  This
-    will be different from SEC if we're recursing up the inheritance chain.
+  def section(me, name):
+    """Return a ConfigSection with the given NAME."""
+    try: return me._sectmap[name]
+    except KeyError: raise MissingSectionException(name)
 
 
-    We also provide the default value for `name' here.
-    """
-    try:
-      raw = CP.RawConfigParser.get(me, sec, key)
-    except CP.NoOptionError:
-      if key == 'name':
-        raw = basesec
-      elif CP.RawConfigParser.has_option(me, sec, '@inherit'):
-        raw = me._get(basesec,
-                      CP.RawConfigParser.get(me, sec, '@inherit'),
-                      key,
-                      resolvep)
-      else:
-        raise
-    return me._expand(basesec, raw, resolvep)
+  def sections(me):
+    """Yield the known sections."""
+    return me._sectmap.itervalues()
 
 
-  def get(me, sec, key, resolvep = True):
-    """
-    Retrieve the value of KEY from section SEC.
-    """
-    return me._get(sec, sec, key, resolvep)
-
-  def items(me, sec, resolvep = True):
+  def resolve(me):
     """
     """
-    Return a list of (NAME, VALUE) items in section SEC.
+    Works out all of the hostnames which need resolving and resolves them.
 
 
-    This extends the default method by handling the inheritance chain.
+    Until you call this, attempts to fetch configuration items which need to
+    resolve hostnames will fail!
     """
     """
-    d = {}
-    basesec = sec
-    while sec:
-      next = None
-      for key, value in CP.RawConfigParser.items(me, sec):
-        if key == '@inherit':
-          next = value
-        elif not key.startswith('@') and key not in d:
-          d[key] = me._expand(basesec, value, resolvep)
-      sec = next
-    return d.items()
+    for sec in me.sections():
+      for key in sec.items():
+        value = sec.get(key, resolvep = False)
+        for match in RX_RESOLVE.finditer(value):
+          me._resolver.prepare(match.group(1))
+    me._resolver.run()
 
 ###--------------------------------------------------------------------------
 ### Command-line handling.
 
 ###--------------------------------------------------------------------------
 ### Command-line handling.
@@ -292,7 +549,7 @@ def getconf(args):
   """
   conf = MyConfigParser()
   for f in inputiter(args):
   """
   conf = MyConfigParser()
   for f in inputiter(args):
-    conf.readfp(f)
+    conf.parse(f)
   conf.resolve()
   return conf
 
   conf.resolve()
   return conf
 
@@ -303,22 +560,24 @@ def output(conf, cdb):
   This is where the special `user' and `auto' database entries get set.
   """
   auto = []
   This is where the special `user' and `auto' database entries get set.
   """
   auto = []
-  for sec in sorted(conf.sections()):
-    if sec.startswith('@'):
+  for sec in sorted(conf.sections(), key = lambda sec: sec.name):
+    if sec.name.startswith('@'):
       continue
       continue
-    elif sec.startswith('$'):
-      label = sec
+    elif sec.name.startswith('$'):
+      label = sec.name
     else:
     else:
-      label = 'P%s' % sec
-      if conf.has_option(sec, 'auto') and \
-         conf.get(sec, 'auto') in ('y', 'yes', 't', 'true', '1', 'on'):
-        auto.append(sec)
-      if conf.has_option(sec, 'user'):
-        cdb.add('U%s' % conf.get(sec, 'user'), sec)
-    url = M.URLEncode(laxp = True, semip = True)
-    for key, value in sorted(conf.items(sec), key = lambda (k, v): k):
+      label = 'P%s' % sec.name
+      try: a = sec.get('auto')
+      except MissingKeyException: pass
+      else:
+        if a in ('y', 'yes', 't', 'true', '1', 'on'): auto.append(sec.name)
+      try: u = sec.get('user')
+      except MissingKeyException: pass
+      else: cdb.add('U%s' % u)
+    url = M.URLEncode(semip = True)
+    for key in sorted(sec.items()):
       if not key.startswith('@'):
       if not key.startswith('@'):
-        url.encode(key, ' '.join(M.split(value)[0]))
+        url.encode(key, sec.get(key))
     cdb.add(label, url.result)
   cdb.add('%AUTO', ' '.join(auto))
   cdb.finish()
     cdb.add(label, url.result)
   cdb.add('%AUTO', ' '.join(auto))
   cdb.finish()
@@ -330,8 +589,12 @@ def main():
     cdb = CDB.cdbmake(opts.cdbfile, opts.cdbfile + '.new')
   else:
     cdb = CDBFake()
     cdb = CDB.cdbmake(opts.cdbfile, opts.cdbfile + '.new')
   else:
     cdb = CDBFake()
-  conf = getconf(args[1:])
-  output(conf, cdb)
+  try:
+    conf = getconf(args[1:])
+    output(conf, cdb)
+  except ExpectedError, e:
+    M.moan(str(e))
+    exit(2)
 
 if __name__ == '__main__':
   main()
 
 if __name__ == '__main__':
   main()