chiark / gitweb /
peerdb/tripe-newpeers.in (BulkResolver): Add exception for resolution failure.
[tripe] / peerdb / tripe-newpeers.in
CommitLineData
6005ef9b
MW
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###
11ad66c2
MW
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.
6005ef9b 17###
11ad66c2
MW
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.
6005ef9b
MW
22###
23### You should have received a copy of the GNU General Public License
11ad66c2 24### along with TrIPE. If not, see <https://www.gnu.org/licenses/>.
6005ef9b
MW
25
26VERSION = '@VERSION@'
27
28###--------------------------------------------------------------------------
29### External dependencies.
30
6005ef9b
MW
31import mLib as M
32from optparse import OptionParser
33import cdb as CDB
34from sys import stdin, stdout, exit, argv
35import re as RX
36import os as OS
b7e5aa06 37from cStringIO import StringIO
6005ef9b
MW
38
39###--------------------------------------------------------------------------
40### Utilities.
41
42class 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
6f48da4a
MW
54class 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
6005ef9b
MW
61class 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."""
d8310a3a
MW
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))
6005ef9b
MW
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:
6f48da4a 102 raise ResolverFailure(host, '(unknown failure)')
6005ef9b
MW
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
b7e5aa06
MW
113## Match a comment or empty line.
114RX_COMMENT = RX.compile(r'(?x) ^ \s* (?: $ | [;#])')
115
116## Match a section group header.
117RX_GRPHDR = RX.compile(r'(?x) ^ \s* \[ (.*) \] \s* $')
118
119## Match an assignment line.
120RX_ASSGN = RX.compile(r'''(?x) ^
121 ([^\s:=] (?: [^:=]* [^\s:=])?)
122 \s* [:=] \s*
123 (| \S | \S.*\S)
124 \s* $''')
125
126## Match a continuation line.
127RX_CONT = RX.compile(r'''(?x) ^ \s+
128 (| \S | \S.*\S)
129 \s* $''')
130
6005ef9b 131## Match a $(VAR) configuration variable reference; group 1 is the VAR.
2d51bc9f 132RX_REF = RX.compile(r'(?x) \$ \( ([^)]+) \)')
6005ef9b
MW
133
134## Match a $[HOST] name resolution reference; group 1 is the HOST.
2d51bc9f 135RX_RESOLVE = RX.compile(r'(?x) \$ \[ ([^]]+) \]')
6005ef9b 136
b7e5aa06
MW
137class 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
bd3db76c
MW
145def _fmt_path(path):
146 return ' -> '.join(["`%s'" % hop for hop in path])
147
148class 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
158class 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
e3ec3a3a
MW
166class MissingSectionException (Exception):
167 def __init__(me, sec):
168 me.key = key
169 def __str__(me):
170 return "Section `%s' not found" % (me.sec)
171
bd3db76c
MW
172class 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
e3ec3a3a
MW
179class 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."""
886350e8
MW
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).
e3ec3a3a
MW
206 me.name = name
207 me._itemmap = dict()
886350e8 208 me._cache = dict()
e3ec3a3a
MW
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
4251f8ad
MW
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
886350e8 233 def _get(me, key, path = None):
e3ec3a3a
MW
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
886350e8 245 ## If we weren't given a path, then we'd better make one.
e3ec3a3a
MW
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
886350e8
MW
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]
e3ec3a3a
MW
258 except KeyError: pass
259 else:
886350e8
MW
260 if p is None: raise InheritanceCycleError(key, path[:])
261 else: return v, path + p
e3ec3a3a
MW
262
263 ## See whether the answer is ready waiting for us.
264 try: v = me._itemmap[key]
265 except KeyError: pass
886350e8
MW
266 else:
267 p = path[:]
268 me._cache[key] = v, []
269 return v, p
e3ec3a3a 270
e3ec3a3a
MW
271 ## Initially we have no idea.
272 value = None
886350e8 273 winner = []
e3ec3a3a
MW
274
275 ## Go through our parents and ask them what they think.
886350e8 276 me._cache[key] = None, None
4251f8ad 277 for p in me._parents():
e3ec3a3a
MW
278
279 ## See whether we get an answer. If not, keep on going.
886350e8 280 v, pp = p._get(key, path)
e3ec3a3a
MW
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.
886350e8 291 me._cache[key] = value, winner[len(path):]
e3ec3a3a
MW
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)
7dd9d51f
MW
306 elif key == '@inherits':
307 try: return me._itemmap['@inherits']
308 except KeyError: raise MissingKeyException(me.name, key)
e3ec3a3a
MW
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 """
85341d9c 319 Yield a list of item names in the section.
e3ec3a3a
MW
320 """
321
322 ## Initialize for a depth-first walk of the inheritance graph.
4063c2b5 323 seen = { 'name': True }
f417591a 324 visiting = { me.name: True }
4251f8ad 325 stack = [me]
e3ec3a3a
MW
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:
4251f8ad 330 sec = stack.pop()
f417591a
MW
331 for p in sec._parents():
332 if p.name not in visiting:
333 stack.append(p); visiting[p.name] = True
e3ec3a3a 334
7dd9d51f 335 for key in sec._itemmap.iterkeys(): seen[key] = None
e3ec3a3a 336
e3ec3a3a 337 ## And we're done.
6e5794ef 338 return seen.iterkeys()
e3ec3a3a 339
b7e5aa06 340class MyConfigParser (object):
6005ef9b
MW
341 """
342 A more advanced configuration parser.
343
b7e5aa06 344 This has four major enhancements over the standard ConfigParser which are
6005ef9b
MW
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
b7e5aa06
MW
356 * Its parsing behaviour is well-defined.
357
6005ef9b
MW
358 Use:
359
b7e5aa06 360 1. Call parse(FILENAME) to slurp in the configuration data.
6005ef9b
MW
361
362 2. Call resolve() to collect the hostnames which need to be resolved and
363 actually do the name resolution.
364
e3ec3a3a
MW
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
6005ef9b
MW
369 iterate over them.
370 """
371
372 def __init__(me):
373 """
374 Initialize a new, empty configuration parser.
375 """
b7e5aa06 376 me._sectmap = dict()
6005ef9b
MW
377 me._resolver = BulkResolver()
378
b7e5aa06
MW
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():
e3ec3a3a 399 if key is not None: sect._itemmap[key] = val.getvalue()
b7e5aa06
MW
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]
e3ec3a3a 419 except KeyError: sect = me._sectmap[name] = ConfigSection(name, me)
b7e5aa06
MW
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
e3ec3a3a
MW
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
b7e5aa06 452 def sections(me):
e3ec3a3a
MW
453 """Yield the known sections."""
454 return me._sectmap.itervalues()
b7e5aa06 455
6005ef9b
MW
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 """
e3ec3a3a 463 for sec in me.sections():
85341d9c
MW
464 for key in sec.items():
465 value = sec.get(key, resolvep = False)
2d51bc9f 466 for match in RX_RESOLVE.finditer(value):
6005ef9b
MW
467 me._resolver.prepare(match.group(1))
468 me._resolver.run()
469
6005ef9b
MW
470###--------------------------------------------------------------------------
471### Command-line handling.
472
473def 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
501def 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
517def 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):
b7e5aa06 525 conf.parse(f)
6005ef9b
MW
526 conf.resolve()
527 return conf
528
529def 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 = []
e3ec3a3a
MW
536 for sec in sorted(conf.sections(), key = lambda sec: sec.name):
537 if sec.name.startswith('@'):
6005ef9b 538 continue
e3ec3a3a
MW
539 elif sec.name.startswith('$'):
540 label = sec.name
6005ef9b 541 else:
e3ec3a3a 542 label = 'P%s' % sec.name
fd1ba90c
MW
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)
6090fc43 550 url = M.URLEncode(semip = True)
85341d9c 551 for key in sorted(sec.items()):
6005ef9b 552 if not key.startswith('@'):
6090fc43 553 url.encode(key, sec.get(key))
6005ef9b
MW
554 cdb.add(label, url.result)
555 cdb.add('%AUTO', ' '.join(auto))
556 cdb.finish()
557
558def 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
568if __name__ == '__main__':
569 main()
570
571###----- That's all, folks --------------------------------------------------