chiark / gitweb /
peerdb/tripe-newpeers.in: Split out a class for a host's resolved names.
[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
1c4623dd
MW
51class ExpectedError (Exception): pass
52
6005ef9b
MW
53###--------------------------------------------------------------------------
54### A bulk DNS resolver.
55
1c4623dd 56class ResolverFailure (ExpectedError):
6f48da4a
MW
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
660564a1
MW
63class 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
6005ef9b
MW
89class 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."""
6005ef9b 107 me._namemap = {}
660564a1
MW
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
6005ef9b
MW
119
120 def run(me):
121 """Run the background DNS resolver until it's finished."""
660564a1 122 while me._noutstand: M.select()
6005ef9b 123
660564a1
MW
124 def lookup(me, name):
125 """Fetch the address corresponding to the host NAME."""
126 return me._namemap[name].get()
6005ef9b
MW
127
128 def _resolved(me, host, addr):
129 """Callback function: remember that ADDR is the address for HOST."""
660564a1
MW
130 if addr is None:
131 host.failed('(unknown failure)')
132 else:
133 host.setaddr(addr)
134 host._resolv = None
135 me._noutstand -= 1
6005ef9b
MW
136
137###--------------------------------------------------------------------------
138### The configuration parser.
139
b7e5aa06
MW
140## Match a comment or empty line.
141RX_COMMENT = RX.compile(r'(?x) ^ \s* (?: $ | [;#])')
142
143## Match a section group header.
144RX_GRPHDR = RX.compile(r'(?x) ^ \s* \[ (.*) \] \s* $')
145
146## Match an assignment line.
147RX_ASSGN = RX.compile(r'''(?x) ^
148 ([^\s:=] (?: [^:=]* [^\s:=])?)
149 \s* [:=] \s*
150 (| \S | \S.*\S)
151 \s* $''')
152
153## Match a continuation line.
154RX_CONT = RX.compile(r'''(?x) ^ \s+
155 (| \S | \S.*\S)
156 \s* $''')
157
6005ef9b 158## Match a $(VAR) configuration variable reference; group 1 is the VAR.
2d51bc9f 159RX_REF = RX.compile(r'(?x) \$ \( ([^)]+) \)')
6005ef9b
MW
160
161## Match a $[HOST] name resolution reference; group 1 is the HOST.
2d51bc9f 162RX_RESOLVE = RX.compile(r'(?x) \$ \[ ([^]]+) \]')
6005ef9b 163
1c4623dd 164class ConfigSyntaxError (ExpectedError):
b7e5aa06
MW
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
bd3db76c
MW
172def _fmt_path(path):
173 return ' -> '.join(["`%s'" % hop for hop in path])
174
1c4623dd 175class AmbiguousOptionError (ExpectedError):
bd3db76c
MW
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
1c4623dd 185class InheritanceCycleError (ExpectedError):
bd3db76c
MW
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
1c4623dd 193class MissingSectionException (ExpectedError):
e3ec3a3a 194 def __init__(me, sec):
260dce8e 195 me.sec = sec
e3ec3a3a
MW
196 def __str__(me):
197 return "Section `%s' not found" % (me.sec)
198
1c4623dd 199class MissingKeyException (ExpectedError):
bd3db76c
MW
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
e3ec3a3a
MW
206class 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."""
886350e8
MW
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).
e3ec3a3a
MW
233 me.name = name
234 me._itemmap = dict()
886350e8 235 me._cache = dict()
e3ec3a3a
MW
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
4251f8ad
MW
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
886350e8 260 def _get(me, key, path = None):
e3ec3a3a
MW
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
886350e8 272 ## If we weren't given a path, then we'd better make one.
e3ec3a3a
MW
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
886350e8
MW
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]
e3ec3a3a
MW
285 except KeyError: pass
286 else:
886350e8
MW
287 if p is None: raise InheritanceCycleError(key, path[:])
288 else: return v, path + p
e3ec3a3a
MW
289
290 ## See whether the answer is ready waiting for us.
291 try: v = me._itemmap[key]
292 except KeyError: pass
886350e8
MW
293 else:
294 p = path[:]
295 me._cache[key] = v, []
296 return v, p
e3ec3a3a 297
e3ec3a3a
MW
298 ## Initially we have no idea.
299 value = None
886350e8 300 winner = []
e3ec3a3a
MW
301
302 ## Go through our parents and ask them what they think.
886350e8 303 me._cache[key] = None, None
4251f8ad 304 for p in me._parents():
e3ec3a3a
MW
305
306 ## See whether we get an answer. If not, keep on going.
886350e8 307 v, pp = p._get(key, path)
e3ec3a3a
MW
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.
886350e8 318 me._cache[key] = value, winner[len(path):]
e3ec3a3a
MW
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)
7dd9d51f
MW
333 elif key == '@inherits':
334 try: return me._itemmap['@inherits']
335 except KeyError: raise MissingKeyException(me.name, key)
e3ec3a3a
MW
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 """
85341d9c 346 Yield a list of item names in the section.
e3ec3a3a
MW
347 """
348
349 ## Initialize for a depth-first walk of the inheritance graph.
4063c2b5 350 seen = { 'name': True }
f417591a 351 visiting = { me.name: True }
4251f8ad 352 stack = [me]
e3ec3a3a
MW
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:
4251f8ad 357 sec = stack.pop()
f417591a
MW
358 for p in sec._parents():
359 if p.name not in visiting:
360 stack.append(p); visiting[p.name] = True
e3ec3a3a 361
7dd9d51f 362 for key in sec._itemmap.iterkeys(): seen[key] = None
e3ec3a3a 363
e3ec3a3a 364 ## And we're done.
6e5794ef 365 return seen.iterkeys()
e3ec3a3a 366
b7e5aa06 367class MyConfigParser (object):
6005ef9b
MW
368 """
369 A more advanced configuration parser.
370
b7e5aa06 371 This has four major enhancements over the standard ConfigParser which are
6005ef9b
MW
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
b7e5aa06
MW
383 * Its parsing behaviour is well-defined.
384
6005ef9b
MW
385 Use:
386
b7e5aa06 387 1. Call parse(FILENAME) to slurp in the configuration data.
6005ef9b
MW
388
389 2. Call resolve() to collect the hostnames which need to be resolved and
390 actually do the name resolution.
391
e3ec3a3a
MW
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
6005ef9b
MW
396 iterate over them.
397 """
398
399 def __init__(me):
400 """
401 Initialize a new, empty configuration parser.
402 """
b7e5aa06 403 me._sectmap = dict()
6005ef9b
MW
404 me._resolver = BulkResolver()
405
b7e5aa06
MW
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():
e3ec3a3a 426 if key is not None: sect._itemmap[key] = val.getvalue()
b7e5aa06
MW
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]
e3ec3a3a 446 except KeyError: sect = me._sectmap[name] = ConfigSection(name, me)
b7e5aa06
MW
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
e3ec3a3a
MW
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
b7e5aa06 479 def sections(me):
e3ec3a3a
MW
480 """Yield the known sections."""
481 return me._sectmap.itervalues()
b7e5aa06 482
6005ef9b
MW
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 """
e3ec3a3a 490 for sec in me.sections():
85341d9c
MW
491 for key in sec.items():
492 value = sec.get(key, resolvep = False)
2d51bc9f 493 for match in RX_RESOLVE.finditer(value):
6005ef9b
MW
494 me._resolver.prepare(match.group(1))
495 me._resolver.run()
496
6005ef9b
MW
497###--------------------------------------------------------------------------
498### Command-line handling.
499
500def 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
528def 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
544def 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):
b7e5aa06 552 conf.parse(f)
6005ef9b
MW
553 conf.resolve()
554 return conf
555
556def 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 = []
e3ec3a3a
MW
563 for sec in sorted(conf.sections(), key = lambda sec: sec.name):
564 if sec.name.startswith('@'):
6005ef9b 565 continue
e3ec3a3a
MW
566 elif sec.name.startswith('$'):
567 label = sec.name
6005ef9b 568 else:
e3ec3a3a 569 label = 'P%s' % sec.name
fd1ba90c
MW
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)
6090fc43 577 url = M.URLEncode(semip = True)
85341d9c 578 for key in sorted(sec.items()):
6005ef9b 579 if not key.startswith('@'):
6090fc43 580 url.encode(key, sec.get(key))
6005ef9b
MW
581 cdb.add(label, url.result)
582 cdb.add('%AUTO', ' '.join(auto))
583 cdb.finish()
584
585def 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()
1c4623dd
MW
592 try:
593 conf = getconf(args[1:])
594 output(conf, cdb)
595 except ExpectedError, e:
596 M.moan(str(e))
597 exit(2)
6005ef9b
MW
598
599if __name__ == '__main__':
600 main()
601
602###----- That's all, folks --------------------------------------------------