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