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