3368295a46ec8d743dee349a503243dcca6b55ac
[tripe] / svc / conntrack.in
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 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 from ConfigParser import RawConfigParser
32 from optparse import OptionParser
33 import os as OS
34 import sys as SYS
35 import socket as S
36 import mLib as M
37 import tripe as T
38 import dbus as D
39 for i in ['mainloop', 'mainloop.glib']:
40 __import__('dbus.%s' % i)
41 try: from gi.repository import GLib as G
42 except ImportError: import gobject as G
43 from struct import pack, unpack
44
45 SM = T.svcmgr
46 ##__import__('rmcr').__debug = True
47
48 ###--------------------------------------------------------------------------
49 ### Utilities.
50
51 class struct (object):
52 """A simple container object."""
53 def __init__(me, **kw):
54 me.__dict__.update(kw)
55
56 def 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
105 class 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)
143 if T._debug: print '# reread config'
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.
195 CF = None
196
197 def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
198 def strmask(m):
199 for i in xrange(33):
200 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return i
201 return straddr(m)
202
203 def cmd_showconfig():
204 T.svcinfo('test-addr=%s' % CF.testaddr)
205 def cmd_showgroups():
206 for sec, pats in CF.groups:
207 T.svcinfo(sec)
208 def 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
220 ###--------------------------------------------------------------------------
221 ### Responding to a network up/down event.
222
223 def 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()
240 def kickpeers():
241 while True:
242 upness, reason = _kick.get()
243 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
244 select = []
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
262 else:
263 addr = None
264 if not T._debug: pass
265 elif addr: print '# local address = %s' % straddr(addr)
266 else: print '# offline'
267
268 ## Now decide what to do.
269 changes = []
270 for g, pp in CF.groups:
271 if T._debug: print '# check group %s' % g
272
273 ## Find out which peer in the group ought to be active.
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)
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))
285 if upness and ip is None and \
286 ipq is not None and (ipq & m) == a:
287 if T._debug: print '# %s: SELECTED' % info
288 map[t] = 'up'
289 select.append('%s=%s' % (g, t))
290 if t == 'down' or t.startswith('down/'):
291 want = None
292 else:
293 want = t
294 ip = ipq
295 else:
296 map[t] = 'down'
297 if T._debug: print '# %s: skipped' % info
298
299 ## Shut down the wrong ones.
300 found = False
301 if T._debug: print '# peer-map = %r' % map
302 for p in peers:
303 what = map.get(p, 'leave')
304 if what == 'up':
305 found = True
306 if T._debug: print '# peer %s: already up' % p
307 elif what == 'down':
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
317 if T._debug: print '# peer %s: bring down' % p
318 changes.append(_)
319
320 ## Start the right one if necessary.
321 if want is not None and not found:
322 def _(want = want):
323 try:
324 list(SM.svcsubmit('connect', 'active', want))
325 except T.TripeError, exc:
326 SM.warn('conntrack', 'connect-failed', want, *exc.args)
327 if T._debug: print '# peer %s: bring up' % want
328 changes.append(_)
329
330 ## Commit the changes.
331 if changes:
332 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
333 for c in changes: c()
334
335 def 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
347 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
348
349 NM_NAME = 'org.freedesktop.NetworkManager'
350 NM_PATH = '/org/freedesktop/NetworkManager'
351 NM_IFACE = NM_NAME
352 NMCA_IFACE = NM_NAME + '.Connection.Active'
353
354 NM_STATE_CONNECTED = 3 #obsolete
355 NM_STATE_CONNECTED_LOCAL = 50
356 NM_STATE_CONNECTED_SITE = 60
357 NM_STATE_CONNECTED_GLOBAL = 70
358 NM_CONNSTATES = set([NM_STATE_CONNECTED,
359 NM_STATE_CONNECTED_LOCAL,
360 NM_STATE_CONNECTED_SITE,
361 NM_STATE_CONNECTED_GLOBAL])
362
363 class NetworkManagerMonitor (object):
364 """
365 Watch NetworkManager signals for changes in network state.
366 """
367
368 ## Strategy. There are two kinds of interesting state transitions for us.
369 ## The first one is the global are-we-connected state, which we'll use to
370 ## toggle network upness on a global level. The second is which connection
371 ## has the default route, which we'll use to tweak which peer in the peer
372 ## group is active. The former is most easily tracked using the signal
373 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
374 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
375 ## look for when a new connection gains the default route.
376
377 def attach(me, bus):
378 try:
379 nm = bus.get_object(NM_NAME, NM_PATH)
380 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
381 if state in NM_CONNSTATES:
382 netupdown(True, ['nm', 'initially-connected'])
383 else:
384 netupdown(False, ['nm', 'initially-disconnected'])
385 except D.DBusException, e:
386 if T._debug: print '# exception attaching to network-manager: %s' % e
387 bus.add_signal_receiver(me._nm_state, 'StateChanged',
388 NM_IFACE, NM_NAME, NM_PATH)
389 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
390 NMCA_IFACE, NM_NAME, None)
391
392 def _nm_state(me, state):
393 if state in NM_CONNSTATES:
394 netupdown(True, ['nm', 'connected'])
395 else:
396 netupdown(False, ['nm', 'disconnected'])
397
398 def _nm_connchange(me, props):
399 if props.get('Default', False):
400 netupdown(True, ['nm', 'default-connection-change'])
401
402 ##--------------------------------------------------------------------------
403 ### Connman monitor.
404
405 CM_NAME = 'net.connman'
406 CM_PATH = '/'
407 CM_IFACE = 'net.connman.Manager'
408
409 class ConnManMonitor (object):
410 """
411 Watch ConnMan signls for changes in network state.
412 """
413
414 ## Strategy. Everything seems to be usefully encoded in the `State'
415 ## property. If it's `offline', `idle' or `ready' then we don't expect a
416 ## network connection. During handover from one network to another, the
417 ## property passes through `ready' to `online'.
418
419 def attach(me, bus):
420 try:
421 cm = bus.get_object(CM_NAME, CM_PATH)
422 props = cm.GetProperties(dbus_interface = CM_IFACE)
423 state = props['State']
424 netupdown(state == 'online', ['connman', 'initially-%s' % state])
425 except D.DBusException, e:
426 if T._debug: print '# exception attaching to connman: %s' % e
427 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
428 CM_IFACE, CM_NAME, CM_PATH)
429
430 def _cm_state(me, prop, value):
431 if prop != 'State': return
432 netupdown(value == 'online', ['connman', value])
433
434 ###--------------------------------------------------------------------------
435 ### Maemo monitor.
436
437 ICD_NAME = 'com.nokia.icd'
438 ICD_PATH = '/com/nokia/icd'
439 ICD_IFACE = ICD_NAME
440
441 class MaemoICdMonitor (object):
442 """
443 Watch ICd signals for changes in network state.
444 """
445
446 ## Strategy. ICd only handles one connection at a time in steady state,
447 ## though when switching between connections, it tries to bring the new one
448 ## up before shutting down the old one. This makes life a bit easier than
449 ## it is with NetworkManager. On the other hand, the notifications are
450 ## relative to particular connections only, and the indicator that the old
451 ## connection is down (`IDLE') comes /after/ the new one comes up
452 ## (`CONNECTED'), so we have to remember which one is active.
453
454 def attach(me, bus):
455 try:
456 icd = bus.get_object(ICD_NAME, ICD_PATH)
457 try:
458 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
459 me._iap = iap
460 netupdown(True, ['icd', 'initially-connected', iap])
461 except D.DBusException:
462 me._iap = None
463 netupdown(False, ['icd', 'initially-disconnected'])
464 except D.DBusException, e:
465 if T._debug: print '# exception attaching to icd: %s' % e
466 me._iap = None
467 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
468 ICD_NAME, ICD_PATH)
469
470 def _icd_state(me, iap, ty, state, hunoz):
471 if state == 'CONNECTED':
472 me._iap = iap
473 netupdown(True, ['icd', 'connected', iap])
474 elif state == 'IDLE' and iap == me._iap:
475 me._iap = None
476 netupdown(False, ['icd', 'idle'])
477
478 ###--------------------------------------------------------------------------
479 ### D-Bus connection tracking.
480
481 class DBusMonitor (object):
482 """
483 Maintains a connection to the system D-Bus, and watches for signals.
484
485 If the connection is initially down, or drops for some reason, we retry
486 periodically (every five seconds at the moment). If the connection
487 resurfaces, we reattach the monitors.
488 """
489
490 def __init__(me):
491 """
492 Initialise the object and try to establish a connection to the bus.
493 """
494 me._mons = []
495 me._loop = D.mainloop.glib.DBusGMainLoop()
496 me._state = 'startup'
497 me._reconnect()
498
499 def addmon(me, mon):
500 """
501 Add a monitor object to watch for signals.
502
503 MON.attach(BUS) is called, with BUS being the connection to the system
504 bus. MON should query its service's current status and watch for
505 relevant signals.
506 """
507 me._mons.append(mon)
508 if me._bus is not None:
509 mon.attach(me._bus)
510
511 def _reconnect(me, hunoz = None):
512 """
513 Start connecting to the bus.
514
515 If we fail the first time, retry periodically.
516 """
517 if me._state == 'startup':
518 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
519 elif me._state == 'connected':
520 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
521 else:
522 T.aside(SM.notify, 'conntrack', 'dbus-connection',
523 'state=%s' % me._state)
524 me._state == 'reconnecting'
525 me._bus = None
526 if me._try_connect():
527 G.timeout_add_seconds(5, me._try_connect)
528
529 def _try_connect(me):
530 """
531 Actually make a connection attempt.
532
533 If we succeed, attach the monitors.
534 """
535 try:
536 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
537 if addr == 'SESSION':
538 bus = D.SessionBus(mainloop = me._loop, private = True)
539 elif addr is not None:
540 bus = D.bus.BusConnection(addr, mainloop = me._loop)
541 else:
542 bus = D.SystemBus(mainloop = me._loop, private = True)
543 for m in me._mons:
544 m.attach(bus)
545 except D.DBusException, e:
546 return True
547 me._bus = bus
548 me._state = 'connected'
549 bus.call_on_disconnection(me._reconnect)
550 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
551 return False
552
553 ###--------------------------------------------------------------------------
554 ### TrIPE service.
555
556 class GIOWatcher (object):
557 """
558 Monitor I/O events using glib.
559 """
560 def __init__(me, conn, mc = G.main_context_default()):
561 me._conn = conn
562 me._watch = None
563 me._mc = mc
564 def connected(me, sock):
565 me._watch = G.io_add_watch(sock, G.IO_IN,
566 lambda *hunoz: me._conn.receive())
567 def disconnected(me):
568 G.source_remove(me._watch)
569 me._watch = None
570 def iterate(me):
571 me._mc.iteration(True)
572
573 SM.iowatch = GIOWatcher(SM)
574
575 def init():
576 """
577 Service initialization.
578
579 Add the D-Bus monitor here, because we might send commands off immediately,
580 and we want to make sure the server connection is up.
581 """
582 global DBM
583 T.Coroutine(kickpeers, name = 'kickpeers').switch()
584 DBM = DBusMonitor()
585 DBM.addmon(NetworkManagerMonitor())
586 DBM.addmon(ConnManMonitor())
587 DBM.addmon(MaemoICdMonitor())
588 G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
589 or True))
590
591 def parse_options():
592 """
593 Parse the command-line options.
594
595 Automatically changes directory to the requested configdir, and turns on
596 debugging. Returns the options object.
597 """
598 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
599 version = '%%prog %s' % VERSION)
600
601 op.add_option('-a', '--admin-socket',
602 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
603 help = 'Select socket to connect to [default %default]')
604 op.add_option('-d', '--directory',
605 metavar = 'DIR', dest = 'dir', default = T.configdir,
606 help = 'Select current diretory [default %default]')
607 op.add_option('-c', '--config',
608 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
609 help = 'Select configuration [default %default]')
610 op.add_option('--daemon', dest = 'daemon',
611 default = False, action = 'store_true',
612 help = 'Become a daemon after successful initialization')
613 op.add_option('--debug', dest = 'debug',
614 default = False, action = 'store_true',
615 help = 'Emit debugging trace information')
616 op.add_option('--startup', dest = 'startup',
617 default = False, action = 'store_true',
618 help = 'Being called as part of the server startup')
619
620 opts, args = op.parse_args()
621 if args: op.error('no arguments permitted')
622 OS.chdir(opts.dir)
623 T._debug = opts.debug
624 return opts
625
626 ## Service table, for running manually.
627 def cmd_updown(upness):
628 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
629 service_info = [('conntrack', VERSION, {
630 'up': (0, None, '', cmd_updown(True)),
631 'down': (0, None, '', cmd_updown(False)),
632 'show-config': (0, 0, '', cmd_showconfig),
633 'show-groups': (0, 0, '', cmd_showgroups),
634 'show-group': (1, 1, 'GROUP', cmd_showgroup)
635 })]
636
637 if __name__ == '__main__':
638 opts = parse_options()
639 CF = Config(opts.conf)
640 T.runservices(opts.tripesock, service_info,
641 init = init, daemon = opts.daemon)
642
643 ###----- That's all, folks --------------------------------------------------