4 ### Service for automatically tracking network connection status
6 ### (c) 2010 Straylight/Edgeware
9 ###----- Licensing notice ---------------------------------------------------
11 ### This file is part of Trivial IP Encryption (TrIPE).
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.
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
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/>.
28 ###--------------------------------------------------------------------------
29 ### External dependencies.
31 from ConfigParser import RawConfigParser
32 from optparse import OptionParser
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
46 ##__import__('rmcr').__debug = True
48 ###--------------------------------------------------------------------------
51 class struct (object):
52 """A simple container object."""
53 def __init__(me, **kw):
54 me.__dict__.update(kw)
56 def toposort(cmp, things):
58 Generate the THINGS in an order consistent with a given partial order.
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.
63 The THINGS may be any finite iterable; it is converted to a list
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
74 order = [{} for i in xrange(n)]
75 rorder = [{} for i in xrange(n)]
78 if i != j and cmp(things[i], things[j]):
82 ## Now we can do the sort.
87 if order[i] is not None:
89 if len(order[i]) == 0:
97 ###--------------------------------------------------------------------------
98 ### Address manipulation.
100 def parse_address(addrstr):
101 return unpack('>L', S.inet_aton(addrstr))[0]
103 def parse_net(netstr):
104 try: sl = netstr.index('/')
105 except ValueError: raise ValueError('missing mask')
106 addr = parse_address(netstr[:sl])
107 if netstr[sl + 1:].isdigit():
108 n = int(netstr[sl + 1:], 10)
109 mask = (1 << 32) - (1 << 32 - n)
111 mask = parse_address(netstr[sl + 1:])
112 if addr&~mask: raise ValueError('network contains bits set beyond mask')
115 def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
118 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return str(i)
121 ###--------------------------------------------------------------------------
122 ### Parse the configuration file.
124 ## Hmm. Should I try to integrate this with the peers database? It's not a
125 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
126 ## this service are largely going to be satellite notes, I don't think
127 ## scalability's going to be a problem.
129 class Config (object):
131 Represents a configuration file.
133 The most interesting thing is probably the `groups' slot, which stores a
134 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
135 list of (TAG, PEER, ADDR, MASK) triples. The implication is that there
136 should be precisely one peer with a name matching NAME-*, and that it
137 should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such
138 that the host's primary IP address (if PEER is None -- or the IP address it
139 would use for communicating with PEER) is within the network defined by
143 def __init__(me, file):
145 Construct a new Config object, reading the given FILE.
148 me._fwatch = M.FWatch(file)
153 See whether the configuration file has been updated.
155 if me._fwatch.update():
160 Internal function to update the configuration from the underlying file.
163 ## Read the configuration. We have no need of the fancy substitutions,
164 ## so turn them all off.
165 cp = RawConfigParser()
167 if T._debug: print '# reread config'
169 ## Save the test address. Make sure it's vaguely sensible. The default
170 ## is probably good for most cases, in fact, since that address isn't
171 ## actually in use. Note that we never send packets to the test address;
172 ## we just use it to discover routing information.
173 if cp.has_option('DEFAULT', 'test-addr'):
174 testaddr = cp.get('DEFAULT', 'test-addr')
175 S.inet_aton(testaddr)
179 ## Scan the configuration file and build the groups structure.
181 for sec in cp.sections():
183 for tag in cp.options(sec):
184 spec = cp.get(sec, tag).split()
186 ## Parse the entry into peer and network.
193 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
194 ## and MASK is either a dotted-quad or a single integer N indicating
195 ## a mask with N leading ones followed by trailing zeroes.
196 addr, mask = parse_net(net)
197 pats.append((tag, peer, addr, mask))
199 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
200 ## In order to make things vaguely sane, we topologically sort the
201 ## patterns so that more specific patterns are checked first.
202 pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
204 (p == pp and m == (m | mm) and aa == (a & mm)),
206 groups.append((sec, pats))
209 me.testaddr = testaddr
212 ### This will be a configuration file.
215 def cmd_showconfig():
216 T.svcinfo('test-addr=%s' % CF.testaddr)
217 def cmd_showgroups():
218 for sec, pats in CF.groups:
220 def cmd_showgroup(g):
221 for s, p in CF.groups:
226 raise T.TripeJobError('unknown-group', g)
227 for t, p, a, m in pats:
229 'target', p or '(default)',
230 'net', '%s/%s' % (straddr(a), strmask(m)))
232 ###--------------------------------------------------------------------------
233 ### Responding to a network up/down event.
237 Return the local IP address used for talking to PEER.
239 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
242 sk.connect((peer, 1))
243 addr, _ = sk.getsockname()
244 addr = parse_address(addr)
256 if _delay is not None:
257 if T._debug: print '# cancel delayed kick'
258 G.source_remove(_delay)
261 def netupdown(upness, reason):
263 Add or kill peers according to whether the network is up or down.
265 UPNESS is true if the network is up, or false if it's down.
268 _kick.put((upness, reason))
270 def delay_netupdown(upness, reason):
275 if T._debug: print '# delayed %s: %s' % (upness, reason)
277 netupdown(upness, reason)
279 if T._debug: print '# delaying %s: %s' % (upness, reason)
280 _delay = G.timeout_add(2000, _func)
284 upness, reason = _kick.get()
285 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
289 ## Make sure the configuration file is up-to-date. Don't worry if we
290 ## can't do anything useful.
293 except Exception, exc:
294 SM.warn('conntrack', 'config-file-error',
295 exc.__class__.__name__, str(exc))
297 ## Find the current list of peers.
300 ## Work out the primary IP address.
302 addr = localaddr(CF.testaddr)
307 if not T._debug: pass
308 elif addr: print '# local address = %s' % straddr(addr)
309 else: print '# offline'
311 ## Now decide what to do.
313 for g, pp in CF.groups:
314 if T._debug: print '# check group %s' % g
316 ## Find out which peer in the group ought to be active.
320 for t, p, a, m in pp:
321 if p is None or not upness:
326 info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
327 t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
328 if upness and ip is None and \
329 ipq is not None and (ipq & m) == a:
330 if T._debug: print '# %s: SELECTED' % info
332 select.append('%s=%s' % (g, t))
333 if t == 'down' or t.startswith('down/'):
340 if T._debug: print '# %s: skipped' % info
342 ## Shut down the wrong ones.
344 if T._debug: print '# peer-map = %r' % map
346 what = map.get(p, 'leave')
349 if T._debug: print '# peer %s: already up' % p
354 except T.TripeError, exc:
355 if exc.args[0] == 'unknown-peer':
356 ## Inherently racy; don't worry about this.
360 if T._debug: print '# peer %s: bring down' % p
363 ## Start the right one if necessary.
364 if want is not None and not found:
367 list(SM.svcsubmit('connect', 'active', want))
368 except T.TripeError, exc:
369 SM.warn('conntrack', 'connect-failed', want, *exc.args)
370 if T._debug: print '# peer %s: bring up' % want
373 ## Commit the changes.
375 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
376 for c in changes: c()
378 ###--------------------------------------------------------------------------
379 ### NetworkManager monitor.
381 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
383 NM_NAME = 'org.freedesktop.NetworkManager'
384 NM_PATH = '/org/freedesktop/NetworkManager'
386 NMCA_IFACE = NM_NAME + '.Connection.Active'
388 NM_STATE_CONNECTED = 3 #obsolete
389 NM_STATE_CONNECTED_LOCAL = 50
390 NM_STATE_CONNECTED_SITE = 60
391 NM_STATE_CONNECTED_GLOBAL = 70
392 NM_CONNSTATES = set([NM_STATE_CONNECTED,
393 NM_STATE_CONNECTED_LOCAL,
394 NM_STATE_CONNECTED_SITE,
395 NM_STATE_CONNECTED_GLOBAL])
397 class NetworkManagerMonitor (object):
399 Watch NetworkManager signals for changes in network state.
402 ## Strategy. There are two kinds of interesting state transitions for us.
403 ## The first one is the global are-we-connected state, which we'll use to
404 ## toggle network upness on a global level. The second is which connection
405 ## has the default route, which we'll use to tweak which peer in the peer
406 ## group is active. The former is most easily tracked using the signal
407 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
408 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
409 ## look for when a new connection gains the default route.
413 nm = bus.get_object(NM_NAME, NM_PATH)
414 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
415 if state in NM_CONNSTATES:
416 netupdown(True, ['nm', 'initially-connected'])
418 netupdown(False, ['nm', 'initially-disconnected'])
419 except D.DBusException, e:
420 if T._debug: print '# exception attaching to network-manager: %s' % e
421 bus.add_signal_receiver(me._nm_state, 'StateChanged',
422 NM_IFACE, NM_NAME, NM_PATH)
423 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
424 NMCA_IFACE, NM_NAME, None)
426 def _nm_state(me, state):
427 if state in NM_CONNSTATES:
428 delay_netupdown(True, ['nm', 'connected'])
430 delay_netupdown(False, ['nm', 'disconnected'])
432 def _nm_connchange(me, props):
433 if props.get('Default', False) or props.get('Default6', False):
434 delay_netupdown(True, ['nm', 'default-connection-change'])
436 ##--------------------------------------------------------------------------
439 CM_NAME = 'net.connman'
441 CM_IFACE = 'net.connman.Manager'
443 class ConnManMonitor (object):
445 Watch ConnMan signls for changes in network state.
448 ## Strategy. Everything seems to be usefully encoded in the `State'
449 ## property. If it's `offline', `idle' or `ready' then we don't expect a
450 ## network connection. During handover from one network to another, the
451 ## property passes through `ready' to `online'.
455 cm = bus.get_object(CM_NAME, CM_PATH)
456 props = cm.GetProperties(dbus_interface = CM_IFACE)
457 state = props['State']
458 netupdown(state == 'online', ['connman', 'initially-%s' % state])
459 except D.DBusException, e:
460 if T._debug: print '# exception attaching to connman: %s' % e
461 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
462 CM_IFACE, CM_NAME, CM_PATH)
464 def _cm_state(me, prop, value):
465 if prop != 'State': return
466 delay_netupdown(value == 'online', ['connman', value])
468 ###--------------------------------------------------------------------------
471 ICD_NAME = 'com.nokia.icd'
472 ICD_PATH = '/com/nokia/icd'
475 class MaemoICdMonitor (object):
477 Watch ICd signals for changes in network state.
480 ## Strategy. ICd only handles one connection at a time in steady state,
481 ## though when switching between connections, it tries to bring the new one
482 ## up before shutting down the old one. This makes life a bit easier than
483 ## it is with NetworkManager. On the other hand, the notifications are
484 ## relative to particular connections only, and the indicator that the old
485 ## connection is down (`IDLE') comes /after/ the new one comes up
486 ## (`CONNECTED'), so we have to remember which one is active.
490 icd = bus.get_object(ICD_NAME, ICD_PATH)
492 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
494 netupdown(True, ['icd', 'initially-connected', iap])
495 except D.DBusException:
497 netupdown(False, ['icd', 'initially-disconnected'])
498 except D.DBusException, e:
499 if T._debug: print '# exception attaching to icd: %s' % e
501 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
504 def _icd_state(me, iap, ty, state, hunoz):
505 if state == 'CONNECTED':
507 delay_netupdown(True, ['icd', 'connected', iap])
508 elif state == 'IDLE' and iap == me._iap:
510 delay_netupdown(False, ['icd', 'idle'])
512 ###--------------------------------------------------------------------------
513 ### D-Bus connection tracking.
515 class DBusMonitor (object):
517 Maintains a connection to the system D-Bus, and watches for signals.
519 If the connection is initially down, or drops for some reason, we retry
520 periodically (every five seconds at the moment). If the connection
521 resurfaces, we reattach the monitors.
526 Initialise the object and try to establish a connection to the bus.
529 me._loop = D.mainloop.glib.DBusGMainLoop()
530 me._state = 'startup'
535 Add a monitor object to watch for signals.
537 MON.attach(BUS) is called, with BUS being the connection to the system
538 bus. MON should query its service's current status and watch for
542 if me._bus is not None:
545 def _reconnect(me, hunoz = None):
547 Start connecting to the bus.
549 If we fail the first time, retry periodically.
551 if me._state == 'startup':
552 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
553 elif me._state == 'connected':
554 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
556 T.aside(SM.notify, 'conntrack', 'dbus-connection',
557 'state=%s' % me._state)
558 me._state == 'reconnecting'
560 if me._try_connect():
561 G.timeout_add_seconds(5, me._try_connect)
563 def _try_connect(me):
565 Actually make a connection attempt.
567 If we succeed, attach the monitors.
570 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
571 if addr == 'SESSION':
572 bus = D.SessionBus(mainloop = me._loop, private = True)
573 elif addr is not None:
574 bus = D.bus.BusConnection(addr, mainloop = me._loop)
576 bus = D.SystemBus(mainloop = me._loop, private = True)
579 except D.DBusException, e:
582 me._state = 'connected'
583 bus.call_on_disconnection(me._reconnect)
584 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
587 ###--------------------------------------------------------------------------
590 class GIOWatcher (object):
592 Monitor I/O events using glib.
594 def __init__(me, conn, mc = G.main_context_default()):
598 def connected(me, sock):
599 me._watch = G.io_add_watch(sock, G.IO_IN,
600 lambda *hunoz: me._conn.receive())
601 def disconnected(me):
602 G.source_remove(me._watch)
605 me._mc.iteration(True)
607 SM.iowatch = GIOWatcher(SM)
611 Service initialization.
613 Add the D-Bus monitor here, because we might send commands off immediately,
614 and we want to make sure the server connection is up.
617 T.Coroutine(kickpeers, name = 'kickpeers').switch()
619 DBM.addmon(NetworkManagerMonitor())
620 DBM.addmon(ConnManMonitor())
621 DBM.addmon(MaemoICdMonitor())
622 G.timeout_add_seconds(30, lambda: (_delay is not None or
623 netupdown(True, ['interval-timer']) or
628 Parse the command-line options.
630 Automatically changes directory to the requested configdir, and turns on
631 debugging. Returns the options object.
633 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
634 version = '%%prog %s' % VERSION)
636 op.add_option('-a', '--admin-socket',
637 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
638 help = 'Select socket to connect to [default %default]')
639 op.add_option('-d', '--directory',
640 metavar = 'DIR', dest = 'dir', default = T.configdir,
641 help = 'Select current diretory [default %default]')
642 op.add_option('-c', '--config',
643 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
644 help = 'Select configuration [default %default]')
645 op.add_option('--daemon', dest = 'daemon',
646 default = False, action = 'store_true',
647 help = 'Become a daemon after successful initialization')
648 op.add_option('--debug', dest = 'debug',
649 default = False, action = 'store_true',
650 help = 'Emit debugging trace information')
651 op.add_option('--startup', dest = 'startup',
652 default = False, action = 'store_true',
653 help = 'Being called as part of the server startup')
655 opts, args = op.parse_args()
656 if args: op.error('no arguments permitted')
658 T._debug = opts.debug
661 ## Service table, for running manually.
662 def cmd_updown(upness):
663 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
664 service_info = [('conntrack', VERSION, {
665 'up': (0, None, '', cmd_updown(True)),
666 'down': (0, None, '', cmd_updown(False)),
667 'show-config': (0, 0, '', cmd_showconfig),
668 'show-groups': (0, 0, '', cmd_showgroups),
669 'show-group': (1, 1, 'GROUP', cmd_showgroup)
672 if __name__ == '__main__':
673 opts = parse_options()
674 CF = Config(opts.conf)
675 T.runservices(opts.tripesock, service_info,
676 init = init, daemon = opts.daemon)
678 ###----- That's all, folks --------------------------------------------------