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 class InetAddress (object):
101 def __init__(me, addrstr, maskstr = None):
102 me.addr = me._addrstr_to_int(addrstr)
105 elif maskstr.isdigit():
106 me.mask = (1 << 32) - (1 << 32 - int(maskstr))
108 me.mask = me._addrstr_to_int(maskstr)
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
119 addrstr = me._int_to_addrstr(me.addr)
123 inv = me.mask ^ ((1 << 32) - 1)
124 if (inv&(inv + 1)) == 0:
125 return '%s/%d' % (addrstr, 32 - inv.bit_length())
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
133 if me.mask != other.mask: return False
134 if me.addr != other.addr: return False
137 def from_sockaddr(cls, sa):
138 addr, port = (lambda a, p: (a, p))(*sa)
139 return cls(addr), port
141 def parse_address(addrstr, maskstr = None):
142 return InetAddress(addrstr, maskstr)
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:])
149 def straddr(a): return a is None and '#<none>' or str(a)
151 ###--------------------------------------------------------------------------
152 ### Parse the configuration file.
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.
159 TESTADDR = InetAddress('1.2.3.4')
161 class Config (object):
163 Represents a configuration file.
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.
174 def __init__(me, file):
176 Construct a new Config object, reading the given FILE.
179 me._fwatch = M.FWatch(file)
184 See whether the configuration file has been updated.
186 if me._fwatch.update():
191 Internal function to update the configuration from the underlying file.
194 ## Read the configuration. We have no need of the fancy substitutions,
195 ## so turn them all off.
196 cp = RawConfigParser()
198 if T._debug: print '# reread config'
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'))
209 ## Scan the configuration file and build the groups structure.
211 for sec in cp.sections():
213 for tag in cp.options(sec):
214 spec = cp.get(sec, tag).split()
216 ## Parse the entry into peer and network.
221 peer = InetAddress(spec[0])
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.
228 pats.append((tag, peer, net))
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): \
235 (p == pp and n.withinp(nn)),
240 me.testaddr = testaddr
243 ### This will be a configuration file.
246 def cmd_showconfig():
247 T.svcinfo('test-addr=%s' % CF.testaddr)
248 def cmd_showgroups():
249 for g in sorted(CF.groups.iterkeys()):
251 def cmd_showgroup(g):
252 try: pats = CF.groups[g]
253 except KeyError: raise T.TripeJobError('unknown-group', g)
256 'target', p and str(p) or '(default)',
259 ###--------------------------------------------------------------------------
260 ### Responding to a network up/down event.
264 Return the local IP address used for talking to PEER.
266 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
269 sk.connect(peer.sockaddr(1))
270 addr = sk.getsockname()
271 return InetAddress.from_sockaddr(addr)[0]
282 if _delay is not None:
283 if T._debug: print '# cancel delayed kick'
284 G.source_remove(_delay)
287 def netupdown(upness, reason):
289 Add or kill peers according to whether the network is up or down.
291 UPNESS is true if the network is up, or false if it's down.
294 _kick.put((upness, reason))
296 def delay_netupdown(upness, reason):
301 if T._debug: print '# delayed %s: %s' % (upness, reason)
303 netupdown(upness, reason)
305 if T._debug: print '# delaying %s: %s' % (upness, reason)
306 _delay = G.timeout_add(2000, _func)
310 upness, reason = _kick.get()
311 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
315 ## Make sure the configuration file is up-to-date. Don't worry if we
316 ## can't do anything useful.
319 except Exception, exc:
320 SM.warn('conntrack', 'config-file-error',
321 exc.__class__.__name__, str(exc))
323 ## Find the current list of peers.
326 ## Work out the primary IP address.
328 addr = localaddr(CF.testaddr)
333 if not T._debug: pass
334 elif addr: print '# local address = %s' % straddr(addr)
335 else: print '# offline'
337 ## Now decide what to do.
339 for g, pp in CF.groups.iteritems():
340 if T._debug: print '# check group %s' % g
342 ## Find out which peer in the group ought to be active.
347 if p is None or not upness:
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
358 select.append('%s=%s' % (g, t))
359 if t == 'down' or t.startswith('down/'):
366 if T._debug: print '# %s: skipped' % info
368 ## Shut down the wrong ones.
370 if T._debug: print '# peer-map = %r' % map
372 what = map.get(p, 'leave')
375 if T._debug: print '# peer %s: already up' % p
380 except T.TripeError, exc:
381 if exc.args[0] == 'unknown-peer':
382 ## Inherently racy; don't worry about this.
386 if T._debug: print '# peer %s: bring down' % p
389 ## Start the right one if necessary.
390 if want is not None and not found:
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
399 ## Commit the changes.
401 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
402 for c in changes: c()
404 ###--------------------------------------------------------------------------
405 ### NetworkManager monitor.
407 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
409 NM_NAME = 'org.freedesktop.NetworkManager'
410 NM_PATH = '/org/freedesktop/NetworkManager'
412 NMCA_IFACE = NM_NAME + '.Connection.Active'
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])
423 class NetworkManagerMonitor (object):
425 Watch NetworkManager signals for changes in network state.
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.
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'])
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)
452 def _nm_state(me, state):
453 if state in NM_CONNSTATES:
454 delay_netupdown(True, ['nm', 'connected'])
456 delay_netupdown(False, ['nm', 'disconnected'])
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'])
462 ##--------------------------------------------------------------------------
465 CM_NAME = 'net.connman'
467 CM_IFACE = 'net.connman.Manager'
469 class ConnManMonitor (object):
471 Watch ConnMan signls for changes in network state.
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'.
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)
490 def _cm_state(me, prop, value):
491 if prop != 'State': return
492 delay_netupdown(value == 'online', ['connman', value])
494 ###--------------------------------------------------------------------------
497 ICD_NAME = 'com.nokia.icd'
498 ICD_PATH = '/com/nokia/icd'
501 class MaemoICdMonitor (object):
503 Watch ICd signals for changes in network state.
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.
516 icd = bus.get_object(ICD_NAME, ICD_PATH)
518 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
520 netupdown(True, ['icd', 'initially-connected', iap])
521 except D.DBusException:
523 netupdown(False, ['icd', 'initially-disconnected'])
524 except D.DBusException, e:
525 if T._debug: print '# exception attaching to icd: %s' % e
527 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
530 def _icd_state(me, iap, ty, state, hunoz):
531 if state == 'CONNECTED':
533 delay_netupdown(True, ['icd', 'connected', iap])
534 elif state == 'IDLE' and iap == me._iap:
536 delay_netupdown(False, ['icd', 'idle'])
538 ###--------------------------------------------------------------------------
539 ### D-Bus connection tracking.
541 class DBusMonitor (object):
543 Maintains a connection to the system D-Bus, and watches for signals.
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.
552 Initialise the object and try to establish a connection to the bus.
555 me._loop = D.mainloop.glib.DBusGMainLoop()
556 me._state = 'startup'
561 Add a monitor object to watch for signals.
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
568 if me._bus is not None:
571 def _reconnect(me, hunoz = None):
573 Start connecting to the bus.
575 If we fail the first time, retry periodically.
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')
582 T.aside(SM.notify, 'conntrack', 'dbus-connection',
583 'state=%s' % me._state)
584 me._state == 'reconnecting'
586 if me._try_connect():
587 G.timeout_add_seconds(5, me._try_connect)
589 def _try_connect(me):
591 Actually make a connection attempt.
593 If we succeed, attach the monitors.
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)
602 bus = D.SystemBus(mainloop = me._loop, private = True)
605 except D.DBusException, e:
608 me._state = 'connected'
609 bus.call_on_disconnection(me._reconnect)
610 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
613 ###--------------------------------------------------------------------------
616 class GIOWatcher (object):
618 Monitor I/O events using glib.
620 def __init__(me, conn, mc = G.main_context_default()):
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)
631 me._mc.iteration(True)
633 SM.iowatch = GIOWatcher(SM)
637 Service initialization.
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.
643 T.Coroutine(kickpeers, name = 'kickpeers').switch()
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
654 Parse the command-line options.
656 Automatically changes directory to the requested configdir, and turns on
657 debugging. Returns the options object.
659 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
660 version = '%%prog %s' % VERSION)
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')
681 opts, args = op.parse_args()
682 if args: op.error('no arguments permitted')
684 T._debug = opts.debug
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)
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)
704 ###----- That's all, folks --------------------------------------------------