b840fc204a6fe7e17b65b0da7f330a25a100df21
[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 ### Address manipulation.
99
100 class InetAddress (object):
101 def __init__(me, addrstr, maskstr = None):
102 me.addr = me._addrstr_to_int(addrstr)
103 if maskstr is None:
104 me.mask = -1
105 elif maskstr.isdigit():
106 me.mask = (1 << 32) - (1 << 32 - int(maskstr))
107 else:
108 me.mask = me._addrstr_to_int(maskstr)
109 if me.addr&~me.mask:
110 raise ValueError('network contains bits set beyond mask')
111 def _addrstr_to_int(me, addrstr):
112 return unpack('>L', S.inet_aton(addrstr))[0]
113 def _int_to_addrstr(me, n):
114 return S.inet_ntoa(pack('>L', n))
115 def sockaddr(me, port = 0):
116 if me.mask != -1: raise ValueError('not a simple address')
117 return me._int_to_addrstr(me.addr), port
118 def __str__(me):
119 addrstr = me._int_to_addrstr(me.addr)
120 if me.mask == -1:
121 return addrstr
122 else:
123 inv = me.mask ^ ((1 << 32) - 1)
124 if (inv&(inv + 1)) == 0:
125 return '%s/%d' % (addrstr, 32 - inv.bit_length())
126 else:
127 return '%s/%s' % (addrstr, me._int_to_addrstr(me.mask))
128 def withinp(me, net):
129 if (me.mask&net.mask) != net.mask: return False
130 if (me.addr ^ net.addr)&net.mask: return False
131 return True
132 def eq(me, other):
133 if me.mask != other.mask: return False
134 if me.addr != other.addr: return False
135 return True
136 @classmethod
137 def from_sockaddr(cls, sa):
138 addr, port = (lambda a, p: (a, p))(*sa)
139 return cls(addr), port
140
141 def parse_address(addrstr, maskstr = None):
142 return InetAddress(addrstr, maskstr)
143
144 def parse_net(netstr):
145 try: sl = netstr.index('/')
146 except ValueError: raise ValueError('missing mask')
147 return parse_address(netstr[:sl], netstr[sl + 1:])
148
149 def straddr(a): return a is None and '#<none>' or str(a)
150
151 ###--------------------------------------------------------------------------
152 ### Parse the configuration file.
153
154 ## Hmm. Should I try to integrate this with the peers database? It's not a
155 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
156 ## this service are largely going to be satellite notes, I don't think
157 ## scalability's going to be a problem.
158
159 TESTADDR = InetAddress('1.2.3.4')
160
161 class Config (object):
162 """
163 Represents a configuration file.
164
165 The most interesting thing is probably the `groups' slot, which stores a
166 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
167 list of (TAG, PEER, NET) triples. The implication is that there should be
168 precisely one peer from the set, and that it should be named TAG, where
169 (TAG, PEER, NET) is the first triple such that the host's primary IP
170 address (if PEER is None -- or the IP address it would use for
171 communicating with PEER) is within the NET.
172 """
173
174 def __init__(me, file):
175 """
176 Construct a new Config object, reading the given FILE.
177 """
178 me._file = file
179 me._fwatch = M.FWatch(file)
180 me._update()
181
182 def check(me):
183 """
184 See whether the configuration file has been updated.
185 """
186 if me._fwatch.update():
187 me._update()
188
189 def _update(me):
190 """
191 Internal function to update the configuration from the underlying file.
192 """
193
194 ## Read the configuration. We have no need of the fancy substitutions,
195 ## so turn them all off.
196 cp = RawConfigParser()
197 cp.read(me._file)
198 if T._debug: print '# reread config'
199
200 ## Save the test address. Make sure it's vaguely sensible. The default
201 ## is probably good for most cases, in fact, since that address isn't
202 ## actually in use. Note that we never send packets to the test address;
203 ## we just use it to discover routing information.
204 if cp.has_option('DEFAULT', 'test-addr'):
205 testaddr = InetAddress(cp.get('DEFAULT', 'test-addr'))
206 else:
207 testaddr = TESTADDR
208
209 ## Scan the configuration file and build the groups structure.
210 groups = {}
211 for sec in cp.sections():
212 pats = []
213 for tag in cp.options(sec):
214 spec = cp.get(sec, tag).split()
215
216 ## Parse the entry into peer and network.
217 if len(spec) == 1:
218 peer = None
219 net = spec[0]
220 else:
221 peer = InetAddress(spec[0])
222 net = spec[1]
223
224 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
225 ## and MASK is either a dotted-quad or a single integer N indicating
226 ## a mask with N leading ones followed by trailing zeroes.
227 net = parse_net(net)
228 pats.append((tag, peer, net))
229
230 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
231 ## In order to make things vaguely sane, we topologically sort the
232 ## patterns so that more specific patterns are checked first.
233 pats = list(toposort(lambda (t, p, n), (tt, pp, nn): \
234 (p and not pp) or \
235 (p == pp and n.withinp(nn)),
236 pats))
237 groups[sec] = pats
238
239 ## Done.
240 me.testaddr = testaddr
241 me.groups = groups
242
243 ### This will be a configuration file.
244 CF = None
245
246 def cmd_showconfig():
247 T.svcinfo('test-addr=%s' % CF.testaddr)
248 def cmd_showgroups():
249 for g in sorted(CF.groups.iterkeys()):
250 T.svcinfo(g)
251 def cmd_showgroup(g):
252 try: pats = CF.groups[g]
253 except KeyError: raise T.TripeJobError('unknown-group', g)
254 for t, p, n in pats:
255 T.svcinfo('peer', t,
256 'target', p and str(p) or '(default)',
257 'net', str(n))
258
259 ###--------------------------------------------------------------------------
260 ### Responding to a network up/down event.
261
262 def localaddr(peer):
263 """
264 Return the local IP address used for talking to PEER.
265 """
266 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
267 try:
268 try:
269 sk.connect(peer.sockaddr(1))
270 addr = sk.getsockname()
271 return InetAddress.from_sockaddr(addr)[0]
272 except S.error:
273 return None
274 finally:
275 sk.close()
276
277 _kick = T.Queue()
278 _delay = None
279
280 def cancel_delay():
281 global _delay
282 if _delay is not None:
283 if T._debug: print '# cancel delayed kick'
284 G.source_remove(_delay)
285 _delay = None
286
287 def netupdown(upness, reason):
288 """
289 Add or kill peers according to whether the network is up or down.
290
291 UPNESS is true if the network is up, or false if it's down.
292 """
293
294 _kick.put((upness, reason))
295
296 def delay_netupdown(upness, reason):
297 global _delay
298 cancel_delay()
299 def _func():
300 global _delay
301 if T._debug: print '# delayed %s: %s' % (upness, reason)
302 _delay = None
303 netupdown(upness, reason)
304 return False
305 if T._debug: print '# delaying %s: %s' % (upness, reason)
306 _delay = G.timeout_add(2000, _func)
307
308 def kickpeers():
309 while True:
310 upness, reason = _kick.get()
311 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
312 select = []
313 cancel_delay()
314
315 ## Make sure the configuration file is up-to-date. Don't worry if we
316 ## can't do anything useful.
317 try:
318 CF.check()
319 except Exception, exc:
320 SM.warn('conntrack', 'config-file-error',
321 exc.__class__.__name__, str(exc))
322
323 ## Find the current list of peers.
324 peers = SM.list()
325
326 ## Work out the primary IP address.
327 if upness:
328 addr = localaddr(CF.testaddr)
329 if addr is None:
330 upness = False
331 else:
332 addr = None
333 if not T._debug: pass
334 elif addr: print '# local address = %s' % straddr(addr)
335 else: print '# offline'
336
337 ## Now decide what to do.
338 changes = []
339 for g, pp in CF.groups.iteritems():
340 if T._debug: print '# check group %s' % g
341
342 ## Find out which peer in the group ought to be active.
343 ip = None
344 map = {}
345 want = None
346 for t, p, n in pp:
347 if p is None or not upness:
348 ipq = addr
349 else:
350 ipq = localaddr(p)
351 if T._debug:
352 info = 'peer=%s; target=%s; net=%s; local=%s' % (
353 t, p or '(default)', n, straddr(ipq))
354 if upness and ip is None and \
355 ipq is not None and ipq.withinp(n):
356 if T._debug: print '# %s: SELECTED' % info
357 map[t] = 'up'
358 select.append('%s=%s' % (g, t))
359 if t == 'down' or t.startswith('down/'):
360 want = None
361 else:
362 want = t
363 ip = ipq
364 else:
365 map[t] = 'down'
366 if T._debug: print '# %s: skipped' % info
367
368 ## Shut down the wrong ones.
369 found = False
370 if T._debug: print '# peer-map = %r' % map
371 for p in peers:
372 what = map.get(p, 'leave')
373 if what == 'up':
374 found = True
375 if T._debug: print '# peer %s: already up' % p
376 elif what == 'down':
377 def _(p = p):
378 try:
379 SM.kill(p)
380 except T.TripeError, exc:
381 if exc.args[0] == 'unknown-peer':
382 ## Inherently racy; don't worry about this.
383 pass
384 else:
385 raise
386 if T._debug: print '# peer %s: bring down' % p
387 changes.append(_)
388
389 ## Start the right one if necessary.
390 if want is not None and not found:
391 def _(want = want):
392 try:
393 list(SM.svcsubmit('connect', 'active', want))
394 except T.TripeError, exc:
395 SM.warn('conntrack', 'connect-failed', want, *exc.args)
396 if T._debug: print '# peer %s: bring up' % want
397 changes.append(_)
398
399 ## Commit the changes.
400 if changes:
401 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
402 for c in changes: c()
403
404 ###--------------------------------------------------------------------------
405 ### NetworkManager monitor.
406
407 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
408
409 NM_NAME = 'org.freedesktop.NetworkManager'
410 NM_PATH = '/org/freedesktop/NetworkManager'
411 NM_IFACE = NM_NAME
412 NMCA_IFACE = NM_NAME + '.Connection.Active'
413
414 NM_STATE_CONNECTED = 3 #obsolete
415 NM_STATE_CONNECTED_LOCAL = 50
416 NM_STATE_CONNECTED_SITE = 60
417 NM_STATE_CONNECTED_GLOBAL = 70
418 NM_CONNSTATES = set([NM_STATE_CONNECTED,
419 NM_STATE_CONNECTED_LOCAL,
420 NM_STATE_CONNECTED_SITE,
421 NM_STATE_CONNECTED_GLOBAL])
422
423 class NetworkManagerMonitor (object):
424 """
425 Watch NetworkManager signals for changes in network state.
426 """
427
428 ## Strategy. There are two kinds of interesting state transitions for us.
429 ## The first one is the global are-we-connected state, which we'll use to
430 ## toggle network upness on a global level. The second is which connection
431 ## has the default route, which we'll use to tweak which peer in the peer
432 ## group is active. The former is most easily tracked using the signal
433 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
434 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
435 ## look for when a new connection gains the default route.
436
437 def attach(me, bus):
438 try:
439 nm = bus.get_object(NM_NAME, NM_PATH)
440 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
441 if state in NM_CONNSTATES:
442 netupdown(True, ['nm', 'initially-connected'])
443 else:
444 netupdown(False, ['nm', 'initially-disconnected'])
445 except D.DBusException, e:
446 if T._debug: print '# exception attaching to network-manager: %s' % e
447 bus.add_signal_receiver(me._nm_state, 'StateChanged',
448 NM_IFACE, NM_NAME, NM_PATH)
449 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
450 NMCA_IFACE, NM_NAME, None)
451
452 def _nm_state(me, state):
453 if state in NM_CONNSTATES:
454 delay_netupdown(True, ['nm', 'connected'])
455 else:
456 delay_netupdown(False, ['nm', 'disconnected'])
457
458 def _nm_connchange(me, props):
459 if props.get('Default', False) or props.get('Default6', False):
460 delay_netupdown(True, ['nm', 'default-connection-change'])
461
462 ##--------------------------------------------------------------------------
463 ### Connman monitor.
464
465 CM_NAME = 'net.connman'
466 CM_PATH = '/'
467 CM_IFACE = 'net.connman.Manager'
468
469 class ConnManMonitor (object):
470 """
471 Watch ConnMan signls for changes in network state.
472 """
473
474 ## Strategy. Everything seems to be usefully encoded in the `State'
475 ## property. If it's `offline', `idle' or `ready' then we don't expect a
476 ## network connection. During handover from one network to another, the
477 ## property passes through `ready' to `online'.
478
479 def attach(me, bus):
480 try:
481 cm = bus.get_object(CM_NAME, CM_PATH)
482 props = cm.GetProperties(dbus_interface = CM_IFACE)
483 state = props['State']
484 netupdown(state == 'online', ['connman', 'initially-%s' % state])
485 except D.DBusException, e:
486 if T._debug: print '# exception attaching to connman: %s' % e
487 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
488 CM_IFACE, CM_NAME, CM_PATH)
489
490 def _cm_state(me, prop, value):
491 if prop != 'State': return
492 delay_netupdown(value == 'online', ['connman', value])
493
494 ###--------------------------------------------------------------------------
495 ### Maemo monitor.
496
497 ICD_NAME = 'com.nokia.icd'
498 ICD_PATH = '/com/nokia/icd'
499 ICD_IFACE = ICD_NAME
500
501 class MaemoICdMonitor (object):
502 """
503 Watch ICd signals for changes in network state.
504 """
505
506 ## Strategy. ICd only handles one connection at a time in steady state,
507 ## though when switching between connections, it tries to bring the new one
508 ## up before shutting down the old one. This makes life a bit easier than
509 ## it is with NetworkManager. On the other hand, the notifications are
510 ## relative to particular connections only, and the indicator that the old
511 ## connection is down (`IDLE') comes /after/ the new one comes up
512 ## (`CONNECTED'), so we have to remember which one is active.
513
514 def attach(me, bus):
515 try:
516 icd = bus.get_object(ICD_NAME, ICD_PATH)
517 try:
518 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
519 me._iap = iap
520 netupdown(True, ['icd', 'initially-connected', iap])
521 except D.DBusException:
522 me._iap = None
523 netupdown(False, ['icd', 'initially-disconnected'])
524 except D.DBusException, e:
525 if T._debug: print '# exception attaching to icd: %s' % e
526 me._iap = None
527 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
528 ICD_NAME, ICD_PATH)
529
530 def _icd_state(me, iap, ty, state, hunoz):
531 if state == 'CONNECTED':
532 me._iap = iap
533 delay_netupdown(True, ['icd', 'connected', iap])
534 elif state == 'IDLE' and iap == me._iap:
535 me._iap = None
536 delay_netupdown(False, ['icd', 'idle'])
537
538 ###--------------------------------------------------------------------------
539 ### D-Bus connection tracking.
540
541 class DBusMonitor (object):
542 """
543 Maintains a connection to the system D-Bus, and watches for signals.
544
545 If the connection is initially down, or drops for some reason, we retry
546 periodically (every five seconds at the moment). If the connection
547 resurfaces, we reattach the monitors.
548 """
549
550 def __init__(me):
551 """
552 Initialise the object and try to establish a connection to the bus.
553 """
554 me._mons = []
555 me._loop = D.mainloop.glib.DBusGMainLoop()
556 me._state = 'startup'
557 me._reconnect()
558
559 def addmon(me, mon):
560 """
561 Add a monitor object to watch for signals.
562
563 MON.attach(BUS) is called, with BUS being the connection to the system
564 bus. MON should query its service's current status and watch for
565 relevant signals.
566 """
567 me._mons.append(mon)
568 if me._bus is not None:
569 mon.attach(me._bus)
570
571 def _reconnect(me, hunoz = None):
572 """
573 Start connecting to the bus.
574
575 If we fail the first time, retry periodically.
576 """
577 if me._state == 'startup':
578 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
579 elif me._state == 'connected':
580 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
581 else:
582 T.aside(SM.notify, 'conntrack', 'dbus-connection',
583 'state=%s' % me._state)
584 me._state == 'reconnecting'
585 me._bus = None
586 if me._try_connect():
587 G.timeout_add_seconds(5, me._try_connect)
588
589 def _try_connect(me):
590 """
591 Actually make a connection attempt.
592
593 If we succeed, attach the monitors.
594 """
595 try:
596 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
597 if addr == 'SESSION':
598 bus = D.SessionBus(mainloop = me._loop, private = True)
599 elif addr is not None:
600 bus = D.bus.BusConnection(addr, mainloop = me._loop)
601 else:
602 bus = D.SystemBus(mainloop = me._loop, private = True)
603 for m in me._mons:
604 m.attach(bus)
605 except D.DBusException, e:
606 return True
607 me._bus = bus
608 me._state = 'connected'
609 bus.call_on_disconnection(me._reconnect)
610 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
611 return False
612
613 ###--------------------------------------------------------------------------
614 ### TrIPE service.
615
616 class GIOWatcher (object):
617 """
618 Monitor I/O events using glib.
619 """
620 def __init__(me, conn, mc = G.main_context_default()):
621 me._conn = conn
622 me._watch = None
623 me._mc = mc
624 def connected(me, sock):
625 me._watch = G.io_add_watch(sock, G.IO_IN,
626 lambda *hunoz: me._conn.receive())
627 def disconnected(me):
628 G.source_remove(me._watch)
629 me._watch = None
630 def iterate(me):
631 me._mc.iteration(True)
632
633 SM.iowatch = GIOWatcher(SM)
634
635 def init():
636 """
637 Service initialization.
638
639 Add the D-Bus monitor here, because we might send commands off immediately,
640 and we want to make sure the server connection is up.
641 """
642 global DBM
643 T.Coroutine(kickpeers, name = 'kickpeers').switch()
644 DBM = DBusMonitor()
645 DBM.addmon(NetworkManagerMonitor())
646 DBM.addmon(ConnManMonitor())
647 DBM.addmon(MaemoICdMonitor())
648 G.timeout_add_seconds(30, lambda: (_delay is not None or
649 netupdown(True, ['interval-timer']) or
650 True))
651
652 def parse_options():
653 """
654 Parse the command-line options.
655
656 Automatically changes directory to the requested configdir, and turns on
657 debugging. Returns the options object.
658 """
659 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
660 version = '%%prog %s' % VERSION)
661
662 op.add_option('-a', '--admin-socket',
663 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
664 help = 'Select socket to connect to [default %default]')
665 op.add_option('-d', '--directory',
666 metavar = 'DIR', dest = 'dir', default = T.configdir,
667 help = 'Select current diretory [default %default]')
668 op.add_option('-c', '--config',
669 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
670 help = 'Select configuration [default %default]')
671 op.add_option('--daemon', dest = 'daemon',
672 default = False, action = 'store_true',
673 help = 'Become a daemon after successful initialization')
674 op.add_option('--debug', dest = 'debug',
675 default = False, action = 'store_true',
676 help = 'Emit debugging trace information')
677 op.add_option('--startup', dest = 'startup',
678 default = False, action = 'store_true',
679 help = 'Being called as part of the server startup')
680
681 opts, args = op.parse_args()
682 if args: op.error('no arguments permitted')
683 OS.chdir(opts.dir)
684 T._debug = opts.debug
685 return opts
686
687 ## Service table, for running manually.
688 def cmd_updown(upness):
689 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
690 service_info = [('conntrack', VERSION, {
691 'up': (0, None, '', cmd_updown(True)),
692 'down': (0, None, '', cmd_updown(False)),
693 'show-config': (0, 0, '', cmd_showconfig),
694 'show-groups': (0, 0, '', cmd_showgroups),
695 'show-group': (1, 1, 'GROUP', cmd_showgroup)
696 })]
697
698 if __name__ == '__main__':
699 opts = parse_options()
700 CF = Config(opts.conf)
701 T.runservices(opts.tripesock, service_info,
702 init = init, daemon = opts.daemon)
703
704 ###----- That's all, folks --------------------------------------------------