chiark / gitweb /
Use the new `mLib' annotations on varargs functions.
[tripe] / svc / conntrack.in
CommitLineData
2ec90437
MW
1#! @PYTHON@
2### -*-python-*-
3###
4### Service for automatically tracking network connection status
5###
6### (c) 2010 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
14### it under the terms of the GNU General Public License as published by
15### the Free Software Foundation; either version 2 of the License, or
16### (at your option) any later version.
17###
18### TrIPE is distributed in the hope that it will be useful,
19### but WITHOUT ANY WARRANTY; without even the implied warranty of
20### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21### GNU General Public License for more details.
22###
23### You should have received a copy of the GNU General Public License
24### along with TrIPE; if not, write to the Free Software Foundation,
25### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
26
27VERSION = '@VERSION@'
28
29###--------------------------------------------------------------------------
30### External dependencies.
31
32from ConfigParser import RawConfigParser
33from optparse import OptionParser
34import os as OS
35import sys as SYS
36import socket as S
37import mLib as M
38import tripe as T
39import dbus as D
40for i in ['mainloop', 'mainloop.glib']:
41 __import__('dbus.%s' % i)
42import gobject as G
43from struct import pack, unpack
44
45SM = T.svcmgr
46##__import__('rmcr').__debug = True
47
48###--------------------------------------------------------------------------
49### Utilities.
50
51class struct (object):
52 """A simple container object."""
53 def __init__(me, **kw):
54 me.__dict__.update(kw)
55
56def toposort(cmp, things):
57 """
58 Generate the THINGS in an order consistent with a given partial order.
59
60 The function CMP(X, Y) should return true if X must precede Y, and false if
61 it doesn't care. If X and Y are equal then it should return false.
62
63 The THINGS may be any finite iterable; it is converted to a list
64 internally.
65 """
66
67 ## Make sure we can index the THINGS, and prepare an ordering table.
68 ## What's going on? The THINGS might not have a helpful equality
69 ## predicate, so it's easier to work with indices. The ordering table will
70 ## remember which THINGS (by index) are considered greater than other
71 ## things.
72 things = list(things)
73 n = len(things)
74 order = [{} for i in xrange(n)]
75 rorder = [{} for i in xrange(n)]
76 for i in xrange(n):
77 for j in xrange(n):
78 if i != j and cmp(things[i], things[j]):
79 order[j][i] = True
80 rorder[i][j] = True
81
82 ## Now we can do the sort.
83 out = []
84 while True:
85 done = True
86 for i in xrange(n):
87 if order[i] is not None:
88 done = False
89 if len(order[i]) == 0:
90 for j in rorder[i]:
91 del order[j][i]
92 yield things[i]
93 order[i] = None
94 if done:
95 break
96
97###--------------------------------------------------------------------------
98### Parse the configuration file.
99
100## Hmm. Should I try to integrate this with the peers database? It's not a
101## good fit; it'd need special hacks in tripe-newpeers. And the use case for
102## this service are largely going to be satellite notes, I don't think
103## scalability's going to be a problem.
104
105class Config (object):
106 """
107 Represents a configuration file.
108
109 The most interesting thing is probably the `groups' slot, which stores a
110 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
111 list of (TAG, PEER, ADDR, MASK) triples. The implication is that there
112 should be precisely one peer with a name matching NAME-*, and that it
113 should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such
114 that the host's primary IP address (if PEER is None -- or the IP address it
115 would use for communicating with PEER) is within the network defined by
116 ADDR/MASK.
117 """
118
119 def __init__(me, file):
120 """
121 Construct a new Config object, reading the given FILE.
122 """
123 me._file = file
124 me._fwatch = M.FWatch(file)
125 me._update()
126
127 def check(me):
128 """
129 See whether the configuration file has been updated.
130 """
131 if me._fwatch.update():
132 me._update()
133
134 def _update(me):
135 """
136 Internal function to update the configuration from the underlying file.
137 """
138
139 ## Read the configuration. We have no need of the fancy substitutions,
140 ## so turn them all off.
141 cp = RawConfigParser()
142 cp.read(me._file)
2d4998c4 143 if T._debug: print '# reread config'
2ec90437
MW
144
145 ## Save the test address. Make sure it's vaguely sensible. The default
146 ## is probably good for most cases, in fact, since that address isn't
147 ## actually in use. Note that we never send packets to the test address;
148 ## we just use it to discover routing information.
149 if cp.has_option('DEFAULT', 'test-addr'):
150 testaddr = cp.get('DEFAULT', 'test-addr')
151 S.inet_aton(testaddr)
152 else:
153 testaddr = '1.2.3.4'
154
155 ## Scan the configuration file and build the groups structure.
156 groups = []
157 for sec in cp.sections():
158 pats = []
159 for tag in cp.options(sec):
160 spec = cp.get(sec, tag).split()
161
162 ## Parse the entry into peer and network.
163 if len(spec) == 1:
164 peer = None
165 net = spec[0]
166 else:
167 peer, net = spec
168
169 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
170 ## and MASK is either a dotted-quad or a single integer N indicating
171 ## a mask with N leading ones followed by trailing zeroes.
172 slash = net.index('/')
173 addr, = unpack('>L', S.inet_aton(net[:slash]))
174 if net.find('.', slash + 1) >= 0:
175 mask, = unpack('>L', S.inet_aton(net[:slash]))
176 else:
177 n = int(net[slash + 1:], 10)
178 mask = (1 << 32) - (1 << 32 - n)
179 pats.append((tag, peer, addr & mask, mask))
180
181 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
182 ## In order to make things vaguely sane, we topologically sort the
183 ## patterns so that more specific patterns are checked first.
184 pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
185 (p and not pp) or \
186 (p == pp and m == (m | mm) and aa == (a & mm)),
187 pats))
188 groups.append((sec, pats))
189
190 ## Done.
191 me.testaddr = testaddr
192 me.groups = groups
193
194### This will be a configuration file.
195CF = None
196
2d4998c4
MW
197def straddr(a): return S.inet_ntoa(pack('>L', a))
198def strmask(m):
199 for i in xrange(33):
200 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return i
201 return straddr(m)
202
203def cmd_showconfig():
204 T.svcinfo('test-addr=%s' % CF.testaddr)
205def cmd_showgroups():
206 for sec, pats in CF.groups:
207 T.svcinfo(sec)
208def cmd_showgroup(g):
209 for s, p in CF.groups:
210 if s == g:
211 pats = p
212 break
213 else:
214 raise T.TripeJobError, 'unknown-group', g
215 for t, p, a, m in pats:
216 T.svcinfo('peer', t,
217 'target', p or '(default)',
218 'net', '%s/%s' % (straddr(a), strmask(m)))
219
2ec90437
MW
220###--------------------------------------------------------------------------
221### Responding to a network up/down event.
222
223def localaddr(peer):
224 """
225 Return the local IP address used for talking to PEER.
226 """
227 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
228 try:
229 try:
230 sk.connect((peer, 1))
231 addr, _ = sk.getsockname()
232 addr, = unpack('>L', S.inet_aton(addr))
233 return addr
234 except S.error:
235 return None
236 finally:
237 sk.close()
238
239_kick = T.Queue()
240def kickpeers():
241 while True:
242 upness, reason = _kick.get()
2d4998c4
MW
243 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
244 select = []
2ec90437
MW
245
246 ## Make sure the configuration file is up-to-date. Don't worry if we
247 ## can't do anything useful.
248 try:
249 CF.check()
250 except Exception, exc:
251 SM.warn('conntrack', 'config-file-error',
252 exc.__class__.__name__, str(exc))
253
254 ## Find the current list of peers.
255 peers = SM.list()
256
257 ## Work out the primary IP address.
258 if upness:
259 addr = localaddr(CF.testaddr)
260 if addr is None:
261 upness = False
b10a8c3d
MW
262 else:
263 addr = None
2d4998c4
MW
264 if not T._debug: pass
265 elif addr: print '# local address = %s' % straddr(addr)
266 else: print '# offline'
2ec90437
MW
267
268 ## Now decide what to do.
269 changes = []
270 for g, pp in CF.groups:
2d4998c4 271 if T._debug: print '# check group %s' % g
2ec90437
MW
272
273 ## Find out which peer in the group ought to be active.
b10a8c3d
MW
274 ip = None
275 map = {}
276 want = None
277 for t, p, a, m in pp:
278 if p is None or not upness:
279 ipq = addr
280 else:
281 ipq = localaddr(p)
2d4998c4
MW
282 if T._debug:
283 info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
284 t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
b10a8c3d
MW
285 if upness and ip is None and \
286 ipq is not None and (ipq & m) == a:
2d4998c4 287 if T._debug: print '# %s: SELECTED' % info
b10a8c3d 288 map[t] = 'up'
2d4998c4 289 select.append('%s=%s' % (g, t))
f2bdb96e
MW
290 if t == 'down' or t.startswith('down/'):
291 want = None
292 else:
293 want = t
b10a8c3d
MW
294 ip = ipq
295 else:
296 map[t] = 'down'
2d4998c4 297 if T._debug: print '# %s: skipped' % info
2ec90437
MW
298
299 ## Shut down the wrong ones.
300 found = False
2d4998c4 301 if T._debug: print '# peer-map = %r' % map
2ec90437 302 for p in peers:
b10a8c3d
MW
303 what = map.get(p, 'leave')
304 if what == 'up':
2ec90437 305 found = True
2d4998c4 306 if T._debug: print '# peer %s: already up' % p
b10a8c3d 307 elif what == 'down':
cf2e4ea6
MW
308 def _(p = p):
309 try:
310 SM.kill(p)
311 except T.TripeError, exc:
312 if exc.args[0] == 'unknown-peer':
313 ## Inherently racy; don't worry about this.
314 pass
315 else:
316 raise
2d4998c4 317 if T._debug: print '# peer %s: bring down' % p
cf2e4ea6 318 changes.append(_)
2ec90437
MW
319
320 ## Start the right one if necessary.
7b7e3c74 321 if want is not None and not found:
cf2e4ea6
MW
322 def _(want = want):
323 try:
324 SM.svcsubmit('connect', 'active', want)
325 except T.TripeError, exc:
326 SM.warn('conntrack', 'connect-failed', want, *exc.args)
2d4998c4 327 if T._debug: print '# peer %s: bring up' % want
cf2e4ea6 328 changes.append(_)
2ec90437
MW
329
330 ## Commit the changes.
331 if changes:
2d4998c4 332 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
2ec90437
MW
333 for c in changes: c()
334
335def netupdown(upness, reason):
336 """
337 Add or kill peers according to whether the network is up or down.
338
339 UPNESS is true if the network is up, or false if it's down.
340 """
341
342 _kick.put((upness, reason))
343
344###--------------------------------------------------------------------------
345### NetworkManager monitor.
346
347NM_NAME = 'org.freedesktop.NetworkManager'
348NM_PATH = '/org/freedesktop/NetworkManager'
349NM_IFACE = NM_NAME
350NMCA_IFACE = NM_NAME + '.Connection.Active'
351
352NM_STATE_CONNECTED = 3
353
354class NetworkManagerMonitor (object):
355 """
356 Watch NetworkManager signals for changes in network state.
357 """
358
359 ## Strategy. There are two kinds of interesting state transitions for us.
360 ## The first one is the global are-we-connected state, which we'll use to
361 ## toggle network upness on a global level. The second is which connection
362 ## has the default route, which we'll use to tweak which peer in the peer
363 ## group is active. The former is most easily tracked using the signal
364 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
365 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
366 ## look for when a new connection gains the default route.
367
368 def attach(me, bus):
369 try:
370 nm = bus.get_object(NM_NAME, NM_PATH)
371 state = nm.Get(NM_IFACE, 'State')
372 if state == NM_STATE_CONNECTED:
373 netupdown(True, ['nm', 'initially-connected'])
374 else:
375 netupdown(False, ['nm', 'initially-disconnected'])
376 except D.DBusException:
377 pass
378 bus.add_signal_receiver(me._nm_state, 'StateChanged', NM_IFACE,
379 NM_NAME, NM_PATH)
380 bus.add_signal_receiver(me._nm_connchange,
381 'PropertiesChanged', NMCA_IFACE,
382 NM_NAME, None)
383
384 def _nm_state(me, state):
385 if state == NM_STATE_CONNECTED:
386 netupdown(True, ['nm', 'connected'])
387 else:
388 netupdown(False, ['nm', 'disconnected'])
389
390 def _nm_connchange(me, props):
391 if props.get('Default', False):
392 netupdown(True, ['nm', 'default-connection-change'])
393
394###--------------------------------------------------------------------------
395### Maemo monitor.
396
397ICD_NAME = 'com.nokia.icd'
398ICD_PATH = '/com/nokia/icd'
399ICD_IFACE = ICD_NAME
400
401class MaemoICdMonitor (object):
402 """
403 Watch ICd signals for changes in network state.
404 """
405
406 ## Strategy. ICd only handles one connection at a time in steady state,
407 ## though when switching between connections, it tries to bring the new one
408 ## up before shutting down the old one. This makes life a bit easier than
409 ## it is with NetworkManager. On the other hand, the notifications are
410 ## relative to particular connections only, and the indicator that the old
411 ## connection is down (`IDLE') comes /after/ the new one comes up
412 ## (`CONNECTED'), so we have to remember which one is active.
413
414 def attach(me, bus):
415 try:
416 icd = bus.get_object(ICD_NAME, ICD_PATH)
417 try:
418 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
419 me._iap = iap
420 netupdown(True, ['icd', 'initially-connected', iap])
421 except D.DBusException:
422 me._iap = None
423 netupdown(False, ['icd', 'initially-disconnected'])
424 except D.DBusException:
425 me._iap = None
426 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
427 ICD_NAME, ICD_PATH)
428
429 def _icd_state(me, iap, ty, state, hunoz):
430 if state == 'CONNECTED':
431 me._iap = iap
432 netupdown(True, ['icd', 'connected', iap])
433 elif state == 'IDLE' and iap == me._iap:
434 me._iap = None
435 netupdown(False, ['icd', 'idle'])
436
437###--------------------------------------------------------------------------
438### D-Bus connection tracking.
439
440class DBusMonitor (object):
441 """
442 Maintains a connection to the system D-Bus, and watches for signals.
443
444 If the connection is initially down, or drops for some reason, we retry
445 periodically (every five seconds at the moment). If the connection
446 resurfaces, we reattach the monitors.
447 """
448
449 def __init__(me):
450 """
451 Initialise the object and try to establish a connection to the bus.
452 """
453 me._mons = []
454 me._loop = D.mainloop.glib.DBusGMainLoop()
7bfa1e06 455 me._state = 'startup'
2ec90437
MW
456 me._reconnect()
457
458 def addmon(me, mon):
459 """
460 Add a monitor object to watch for signals.
461
462 MON.attach(BUS) is called, with BUS being the connection to the system
463 bus. MON should query its service's current status and watch for
464 relevant signals.
465 """
466 me._mons.append(mon)
467 if me._bus is not None:
468 mon.attach(me._bus)
469
16650038 470 def _reconnect(me, hunoz = None):
2ec90437
MW
471 """
472 Start connecting to the bus.
473
474 If we fail the first time, retry periodically.
475 """
7bfa1e06
MW
476 if me._state == 'startup':
477 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
478 elif me._state == 'connected':
479 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
480 else:
481 T.aside(SM.notify, 'conntrack', 'dbus-connection',
482 'state=%s' % me._state)
483 me._state == 'reconnecting'
2ec90437
MW
484 me._bus = None
485 if me._try_connect():
486 G.timeout_add_seconds(5, me._try_connect)
487
488 def _try_connect(me):
489 """
490 Actually make a connection attempt.
491
492 If we succeed, attach the monitors.
493 """
494 try:
7bfa1e06
MW
495 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
496 if addr == 'SESSION':
497 bus = D.SessionBus(mainloop = me._loop, private = True)
498 elif addr is not None:
499 bus = D.bus.BusConnection(addr, mainloop = me._loop)
500 else:
501 bus = D.SystemBus(mainloop = me._loop, private = True)
502 for m in me._mons:
503 m.attach(bus)
504 except D.DBusException, e:
2ec90437
MW
505 return True
506 me._bus = bus
7bfa1e06 507 me._state = 'connected'
2ec90437 508 bus.call_on_disconnection(me._reconnect)
7bfa1e06 509 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
2ec90437
MW
510 return False
511
512###--------------------------------------------------------------------------
513### TrIPE service.
514
515class GIOWatcher (object):
516 """
517 Monitor I/O events using glib.
518 """
519 def __init__(me, conn, mc = G.main_context_default()):
520 me._conn = conn
521 me._watch = None
522 me._mc = mc
523 def connected(me, sock):
524 me._watch = G.io_add_watch(sock, G.IO_IN,
525 lambda *hunoz: me._conn.receive())
526 def disconnected(me):
527 G.source_remove(me._watch)
528 me._watch = None
529 def iterate(me):
530 me._mc.iteration(True)
531
532SM.iowatch = GIOWatcher(SM)
533
534def init():
535 """
536 Service initialization.
537
538 Add the D-Bus monitor here, because we might send commands off immediately,
539 and we want to make sure the server connection is up.
540 """
29807d89 541 global DBM
22b47552 542 T.Coroutine(kickpeers, name = 'kickpeers').switch()
29807d89
MW
543 DBM = DBusMonitor()
544 DBM.addmon(NetworkManagerMonitor())
545 DBM.addmon(MaemoICdMonitor())
546 G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
547 or True))
2ec90437
MW
548
549def parse_options():
550 """
551 Parse the command-line options.
552
553 Automatically changes directory to the requested configdir, and turns on
554 debugging. Returns the options object.
555 """
556 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
557 version = '%%prog %s' % VERSION)
558
559 op.add_option('-a', '--admin-socket',
560 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
561 help = 'Select socket to connect to [default %default]')
562 op.add_option('-d', '--directory',
563 metavar = 'DIR', dest = 'dir', default = T.configdir,
564 help = 'Select current diretory [default %default]')
565 op.add_option('-c', '--config',
566 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
567 help = 'Select configuration [default %default]')
568 op.add_option('--daemon', dest = 'daemon',
569 default = False, action = 'store_true',
570 help = 'Become a daemon after successful initialization')
571 op.add_option('--debug', dest = 'debug',
572 default = False, action = 'store_true',
573 help = 'Emit debugging trace information')
574 op.add_option('--startup', dest = 'startup',
575 default = False, action = 'store_true',
576 help = 'Being called as part of the server startup')
577
578 opts, args = op.parse_args()
579 if args: op.error('no arguments permitted')
580 OS.chdir(opts.dir)
581 T._debug = opts.debug
582 return opts
583
584## Service table, for running manually.
585def cmd_updown(upness):
586 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
587service_info = [('conntrack', VERSION, {
588 'up': (0, None, '', cmd_updown(True)),
2d4998c4
MW
589 'down': (0, None, '', cmd_updown(False)),
590 'show-config': (0, 0, '', cmd_showconfig),
591 'show-groups': (0, 0, '', cmd_showgroups),
592 'show-group': (1, 1, 'GROUP', cmd_showgroup)
2ec90437
MW
593})]
594
595if __name__ == '__main__':
596 opts = parse_options()
597 CF = Config(opts.conf)
598 T.runservices(opts.tripesock, service_info,
599 init = init, daemon = opts.daemon)
600
601###----- That's all, folks --------------------------------------------------