chiark / gitweb /
peerdb/tripe-newpeers.in (BulkResolver): Add exception for resolution failure.
[tripe] / peerdb / tripe-newpeers.in
1 #! @PYTHON@
2 ### -*-python-*-
3 ###
4 ### Build a CDB file from configuration file
5 ###
6 ### (c) 2007 Straylight/Edgeware
7 ###
8
9 ###----- Licensing notice ---------------------------------------------------
10 ###
11 ### This file is part of Trivial IP Encryption (TrIPE).
12 ###
13 ### TrIPE is free software: you can redistribute it and/or modify it under
14 ### the terms of the GNU General Public License as published by the Free
15 ### Software Foundation; either version 3 of the License, or (at your
16 ### option) any later version.
17 ###
18 ### TrIPE is distributed in the hope that it will be useful, but WITHOUT
19 ### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
20 ### FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
21 ### for more details.
22 ###
23 ### You should have received a copy of the GNU General Public License
24 ### along with TrIPE.  If not, see <https://www.gnu.org/licenses/>.
25
26 VERSION = '@VERSION@'
27
28 ###--------------------------------------------------------------------------
29 ### External dependencies.
30
31 import mLib as M
32 from optparse import OptionParser
33 import cdb as CDB
34 from sys import stdin, stdout, exit, argv
35 import re as RX
36 import os as OS
37 from cStringIO import StringIO
38
39 ###--------------------------------------------------------------------------
40 ### Utilities.
41
42 class CDBFake (object):
43   """Like cdbmake, but just outputs data suitable for cdb-map."""
44   def __init__(me, file = stdout):
45     me.file = file
46   def add(me, key, value):
47     me.file.write('%s:%s\n' % (key, value))
48   def finish(me):
49     pass
50
51 ###--------------------------------------------------------------------------
52 ### A bulk DNS resolver.
53
54 class ResolverFailure (Exception):
55   def __init__(me, host, msg):
56     me.host = host
57     me.msg = msg
58   def __str__(me):
59     return "failed to resolve `%s': %s" % (me.host, me.msg)
60
61 class BulkResolver (object):
62   """
63   Resolve a number of DNS names in parallel.
64
65   The BulkResovler resolves a number of hostnames in parallel.  Using it
66   works in three phases:
67
68     1. You call prepare(HOSTNAME) a number of times, to feed in the hostnames
69        you're interested in.
70
71     2. You call run() to actually drive the resolver.
72
73     3. You call lookup(HOSTNAME) to get the address you wanted.  This will
74        fail with KeyError if the resolver couldn't resolve the HOSTNAME.
75   """
76
77   def __init__(me):
78     """Initialize the resolver."""
79     me._resolvers = {}
80     me._namemap = {}
81
82   def prepare(me, host):
83     """Prime the resolver to resolve the name HOST."""
84     if host not in me._resolvers:
85       me._resolvers[host] = M.SelResolveByName \
86                             (host,
87                              lambda name, alias, addr:
88                                me._resolved(host, addr[0]),
89                              lambda: me._resolved(host, None))
90
91   def run(me):
92     """Run the background DNS resolver until it's finished."""
93     while me._resolvers:
94       M.select()
95
96   def lookup(me, host):
97     """
98     Fetch the address corresponding to HOST.
99     """
100     addr = me._namemap[host]
101     if addr is None:
102       raise ResolverFailure(host, '(unknown failure)')
103     return addr
104
105   def _resolved(me, host, addr):
106     """Callback function: remember that ADDR is the address for HOST."""
107     me._namemap[host] = addr
108     del me._resolvers[host]
109
110 ###--------------------------------------------------------------------------
111 ### The configuration parser.
112
113 ## Match a comment or empty line.
114 RX_COMMENT = RX.compile(r'(?x) ^ \s* (?: $ | [;#])')
115
116 ## Match a section group header.
117 RX_GRPHDR = RX.compile(r'(?x) ^ \s* \[ (.*) \] \s* $')
118
119 ## Match an assignment line.
120 RX_ASSGN = RX.compile(r'''(?x) ^
121         ([^\s:=] (?: [^:=]* [^\s:=])?)
122         \s* [:=] \s*
123         (| \S | \S.*\S)
124         \s* $''')
125
126 ## Match a continuation line.
127 RX_CONT = RX.compile(r'''(?x) ^ \s+
128         (| \S | \S.*\S)
129         \s* $''')
130
131 ## Match a $(VAR) configuration variable reference; group 1 is the VAR.
132 RX_REF = RX.compile(r'(?x) \$ \( ([^)]+) \)')
133
134 ## Match a $[HOST] name resolution reference; group 1 is the HOST.
135 RX_RESOLVE = RX.compile(r'(?x) \$ \[ ([^]]+) \]')
136
137 class ConfigSyntaxError (Exception):
138   def __init__(me, fname, lno, msg):
139     me.fname = fname
140     me.lno = lno
141     me.msg = msg
142   def __str__(me):
143     return '%s:%d: %s' % (me.fname, me.lno, me.msg)
144
145 def _fmt_path(path):
146   return ' -> '.join(["`%s'" % hop for hop in path])
147
148 class AmbiguousOptionError (Exception):
149   def __init__(me, key, patha, vala, pathb, valb):
150     me.key = key
151     me.patha, me.vala = patha, vala
152     me.pathb, me.valb = pathb, valb
153   def __str__(me):
154     return "Ambiguous answer resolving key `%s': " \
155         "path %s yields `%s' but %s yields `%s'" % \
156         (me.key, _fmt_path(me.patha), me.vala, _fmt_path(me.pathb), me.valb)
157
158 class InheritanceCycleError (Exception):
159   def __init__(me, key, path):
160     me.key = key
161     me.path = path
162   def __str__(me):
163     return "Found a cycle %s looking up key `%s'" % \
164         (_fmt_path(me.path), me.key)
165
166 class MissingSectionException (Exception):
167   def __init__(me, sec):
168     me.key = key
169   def __str__(me):
170     return "Section `%s' not found" % (me.sec)
171
172 class MissingKeyException (Exception):
173   def __init__(me, sec, key):
174     me.sec = sec
175     me.key = key
176   def __str__(me):
177     return "Key `%s' not found in section `%s'" % (me.key, me.sec)
178
179 class ConfigSection (object):
180   """
181   A section in a configuration parser.
182
183   This is where a lot of the nitty-gritty stuff actually happens.  The
184   `MyConfigParser' knows a lot about the internals of this class, which saves
185   on building a complicated interface.
186   """
187
188   def __init__(me, name, cp):
189     """Initialize a new, empty section with a given NAME and parent CP."""
190
191     ## The cache maps item keys to entries, which consist of a pair of
192     ## objects.  There are four possible states for a cache entry:
193     ##
194     ##   * missing -- there is no entry at all with this key, so we must
195     ##     search for it;
196     ##
197     ##   * None, None -- we are actively trying to resolve this key, so if we
198     ##     encounter this state, we have found a cycle in the inheritance
199     ##     graph;
200     ##
201     ##   * None, [] -- we know that this key isn't reachable through any of
202     ##     our parents;
203     ##
204     ##   * VALUE, PATH -- we know that the key resolves to VALUE, along the
205     ##     PATH from us (exclusive) to the defining parent (inclusive).
206     me.name = name
207     me._itemmap = dict()
208     me._cache = dict()
209     me._cp = cp
210
211   def _expand(me, string, resolvep):
212     """
213     Expands $(...) and (optionally) $[...] placeholders in STRING.
214
215     RESOLVEP is a boolean switch: do we bother to tax the resolver or not?
216     This is turned off by MyConfigParser's resolve() method while it's
217     collecting hostnames to be resolved.
218     """
219     string = RX_REF.sub \
220              (lambda m: me.get(m.group(1), resolvep), string)
221     if resolvep:
222       string = RX_RESOLVE.sub(lambda m: me._cp._resolver.lookup(m.group(1)),
223                               string)
224     return string
225
226   def _parents(me):
227     """Yield this section's parents."""
228     try: names = me._itemmap['@inherit']
229     except KeyError: return
230     for name in names.replace(',', ' ').split():
231       yield me._cp.section(name)
232
233   def _get(me, key, path = None):
234     """
235     Low-level option-fetching method.
236
237     Fetch the value for the named KEY in this section, or maybe (recursively)
238     a section which it inherits from.
239
240     Returns a pair VALUE, PATH.  The value is not expanded; nor do we check
241     for the special `name' key.  The caller is expected to do these things.
242     Returns None if no value could be found.
243     """
244
245     ## If we weren't given a path, then we'd better make one.
246     if path is None: path = []
247
248     ## Extend the path to cover us, but remember to remove us again when
249     ## we've finished.  If we need to pass the current path back upwards,
250     ## then remember to take a copy.
251     path.append(me.name)
252     try:
253
254       ## If we've been this way before on another pass through then return the
255       ## value we found then.  If we're still thinking about it then we've
256       ## found a cycle.
257       try: v, p = me._cache[key]
258       except KeyError: pass
259       else:
260         if p is None: raise InheritanceCycleError(key, path[:])
261         else: return v, path + p
262
263       ## See whether the answer is ready waiting for us.
264       try: v = me._itemmap[key]
265       except KeyError: pass
266       else:
267         p = path[:]
268         me._cache[key] = v, []
269         return v, p
270
271       ## Initially we have no idea.
272       value = None
273       winner = []
274
275       ## Go through our parents and ask them what they think.
276       me._cache[key] = None, None
277       for p in me._parents():
278
279         ## See whether we get an answer.  If not, keep on going.
280         v, pp = p._get(key, path)
281         if v is None: continue
282
283         ## If we got an answer, check that it matches any previous ones.
284         if value is None:
285           value = v
286           winner = pp
287         elif value != v:
288           raise AmbiguousOptionError(key, winner, value, pp, v)
289
290       ## That's the best we could manage.
291       me._cache[key] = value, winner[len(path):]
292       return value, winner
293
294     finally:
295       ## Remove us from the path again.
296       path.pop()
297
298   def get(me, key, resolvep = True):
299     """
300     Retrieve the value of KEY from this section.
301     """
302
303     ## Special handling for the `name' key.
304     if key == 'name':
305       value = me._itemmap.get('name', me.name)
306     elif key == '@inherits':
307       try: return me._itemmap['@inherits']
308       except KeyError: raise MissingKeyException(me.name, key)
309     else:
310       value, _ = me._get(key)
311       if value is None:
312         raise MissingKeyException(me.name, key)
313
314     ## Expand the value and return it.
315     return me._expand(value, resolvep)
316
317   def items(me, resolvep = True):
318     """
319     Yield a list of item names in the section.
320     """
321
322     ## Initialize for a depth-first walk of the inheritance graph.
323     seen = { 'name': True }
324     visiting = { me.name: True }
325     stack = [me]
326
327     ## Visit nodes, collecting their keys.  Don't believe the values:
328     ## resolving inheritance is too hard to do like this.
329     while stack:
330       sec = stack.pop()
331       for p in sec._parents():
332         if p.name not in visiting:
333           stack.append(p); visiting[p.name] = True
334
335       for key in sec._itemmap.iterkeys(): seen[key] = None
336
337     ## And we're done.
338     return seen.iterkeys()
339
340 class MyConfigParser (object):
341   """
342   A more advanced configuration parser.
343
344   This has four major enhancements over the standard ConfigParser which are
345   relevant to us.
346
347     * It recognizes `@inherits' keys and follows them when expanding a
348       value.
349
350     * It recognizes `$(VAR)' references to configuration variables during
351       expansion and processes them correctly.
352
353     * It recognizes `$[HOST]' name-resolver requests and handles them
354       correctly.
355
356     * Its parsing behaviour is well-defined.
357
358   Use:
359
360     1. Call parse(FILENAME) to slurp in the configuration data.
361
362     2. Call resolve() to collect the hostnames which need to be resolved and
363        actually do the name resolution.
364
365     3. Call sections() to get a list of the configuration sections, or
366        section(NAME) to find a named section.
367
368     4. Call get(ITEM) on a section to collect the results, or items() to
369        iterate over them.
370   """
371
372   def __init__(me):
373     """
374     Initialize a new, empty configuration parser.
375     """
376     me._sectmap = dict()
377     me._resolver = BulkResolver()
378
379   def parse(me, f):
380     """
381     Parse configuration from a file F.
382     """
383
384     ## Initial parser state.
385     sect = None
386     key = None
387     val = None
388     lno = 0
389
390     ## An unpleasant hack.  Python makes it hard to capture a value in a
391     ## variable and examine it in a single action, and this is the best that
392     ## I came up with.
393     m = [None]
394     def match(rx): m[0] = rx.match(line); return m[0]
395
396     ## Commit a key's value when we've determined that there are no further
397     ## continuation lines.
398     def flush():
399       if key is not None: sect._itemmap[key] = val.getvalue()
400
401     ## Work through all of the input lines.
402     for line in f:
403       lno += 1
404
405       if match(RX_COMMENT):
406         ## A comment or a blank line.  Nothing doing.  (This means that we
407         ## leave out blank lines which look like they might be continuation
408         ## lines.)
409
410         pass
411
412       elif match(RX_GRPHDR):
413         ## A section header.  Flush out any previous value and set up the new
414         ## group.
415
416         flush()
417         name = m[0].group(1)
418         try: sect = me._sectmap[name]
419         except KeyError: sect = me._sectmap[name] = ConfigSection(name, me)
420         key = None
421
422       elif match(RX_ASSGN):
423         ## A new assignment.  Flush out the old one, and set up to store this
424         ## one.
425
426         if sect is None:
427           raise ConfigSyntaxError(f.name, lno, 'no active section to update')
428         flush()
429         key = m[0].group(1)
430         val = StringIO(); val.write(m[0].group(2))
431
432       elif match(RX_CONT):
433         ## A continuation line.  Accumulate the value.
434
435         if key is None:
436           raise ConfigSyntaxError(f.name, lno, 'no config value to continue')
437         val.write('\n'); val.write(m[0].group(1))
438
439       else:
440         ## Something else.
441
442         raise ConfigSyntaxError(f.name, lno, 'incomprehensible line')
443
444     ## Don't forget to commit any final value material.
445     flush()
446
447   def section(me, name):
448     """Return a ConfigSection with the given NAME."""
449     try: return me._sectmap[name]
450     except KeyError: raise MissingSectionException(name)
451
452   def sections(me):
453     """Yield the known sections."""
454     return me._sectmap.itervalues()
455
456   def resolve(me):
457     """
458     Works out all of the hostnames which need resolving and resolves them.
459
460     Until you call this, attempts to fetch configuration items which need to
461     resolve hostnames will fail!
462     """
463     for sec in me.sections():
464       for key in sec.items():
465         value = sec.get(key, resolvep = False)
466         for match in RX_RESOLVE.finditer(value):
467           me._resolver.prepare(match.group(1))
468     me._resolver.run()
469
470 ###--------------------------------------------------------------------------
471 ### Command-line handling.
472
473 def inputiter(things):
474   """
475   Iterate over command-line arguments, returning corresponding open files.
476
477   If none were given, or one is `-', assume standard input; if one is a
478   directory, scan it for files other than backups; otherwise return the
479   opened files.
480   """
481
482   if not things:
483     if OS.isatty(stdin.fileno()):
484       M.die('no input given, and stdin is a terminal')
485     yield stdin
486   else:
487     for thing in things:
488       if thing == '-':
489         yield stdin
490       elif OS.path.isdir(thing):
491         for item in OS.listdir(thing):
492           if item.endswith('~') or item.endswith('#'):
493             continue
494           name = OS.path.join(thing, item)
495           if not OS.path.isfile(name):
496             continue
497           yield file(name)
498       else:
499         yield file(thing)
500
501 def parse_options(argv = argv):
502   """
503   Parse command-line options, returning a pair (OPTS, ARGS).
504   """
505   M.ego(argv[0])
506   op = OptionParser(usage = '%prog [-c CDB] INPUT...',
507                     version = '%%prog (tripe, version %s)' % VERSION)
508   op.add_option('-c', '--cdb', metavar = 'CDB',
509                 dest = 'cdbfile', default = None,
510                 help = 'Compile output into a CDB file.')
511   opts, args = op.parse_args(argv)
512   return opts, args
513
514 ###--------------------------------------------------------------------------
515 ### Main code.
516
517 def getconf(args):
518   """
519   Read the configuration files and return the accumulated result.
520
521   We make sure that all hostnames have been properly resolved.
522   """
523   conf = MyConfigParser()
524   for f in inputiter(args):
525     conf.parse(f)
526   conf.resolve()
527   return conf
528
529 def output(conf, cdb):
530   """
531   Output the configuration information CONF to the database CDB.
532
533   This is where the special `user' and `auto' database entries get set.
534   """
535   auto = []
536   for sec in sorted(conf.sections(), key = lambda sec: sec.name):
537     if sec.name.startswith('@'):
538       continue
539     elif sec.name.startswith('$'):
540       label = sec.name
541     else:
542       label = 'P%s' % sec.name
543       try: a = sec.get('auto')
544       except MissingKeyException: pass
545       else:
546         if a in ('y', 'yes', 't', 'true', '1', 'on'): auto.append(sec.name)
547       try: u = sec.get('user')
548       except MissingKeyException: pass
549       else: cdb.add('U%s' % u)
550     url = M.URLEncode(semip = True)
551     for key in sorted(sec.items()):
552       if not key.startswith('@'):
553         url.encode(key, sec.get(key))
554     cdb.add(label, url.result)
555   cdb.add('%AUTO', ' '.join(auto))
556   cdb.finish()
557
558 def main():
559   """Main program."""
560   opts, args = parse_options()
561   if opts.cdbfile:
562     cdb = CDB.cdbmake(opts.cdbfile, opts.cdbfile + '.new')
563   else:
564     cdb = CDBFake()
565   conf = getconf(args[1:])
566   output(conf, cdb)
567
568 if __name__ == '__main__':
569   main()
570
571 ###----- That's all, folks --------------------------------------------------