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 def parse_address(addrstr):
98 return unpack('>L', S.inet_aton(addrstr))[0]
100 ###--------------------------------------------------------------------------
101 ### Parse the configuration file.
103 ## Hmm. Should I try to integrate this with the peers database? It's not a
104 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
105 ## this service are largely going to be satellite notes, I don't think
106 ## scalability's going to be a problem.
108 class Config (object):
110 Represents a configuration file.
112 The most interesting thing is probably the `groups' slot, which stores a
113 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
114 list of (TAG, PEER, ADDR, MASK) triples. The implication is that there
115 should be precisely one peer with a name matching NAME-*, and that it
116 should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such
117 that the host's primary IP address (if PEER is None -- or the IP address it
118 would use for communicating with PEER) is within the network defined by
122 def __init__(me, file):
124 Construct a new Config object, reading the given FILE.
127 me._fwatch = M.FWatch(file)
132 See whether the configuration file has been updated.
134 if me._fwatch.update():
139 Internal function to update the configuration from the underlying file.
142 ## Read the configuration. We have no need of the fancy substitutions,
143 ## so turn them all off.
144 cp = RawConfigParser()
146 if T._debug: print '# reread config'
148 ## Save the test address. Make sure it's vaguely sensible. The default
149 ## is probably good for most cases, in fact, since that address isn't
150 ## actually in use. Note that we never send packets to the test address;
151 ## we just use it to discover routing information.
152 if cp.has_option('DEFAULT', 'test-addr'):
153 testaddr = cp.get('DEFAULT', 'test-addr')
154 S.inet_aton(testaddr)
158 ## Scan the configuration file and build the groups structure.
160 for sec in cp.sections():
162 for tag in cp.options(sec):
163 spec = cp.get(sec, tag).split()
165 ## Parse the entry into peer and network.
172 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
173 ## and MASK is either a dotted-quad or a single integer N indicating
174 ## a mask with N leading ones followed by trailing zeroes.
175 slash = net.index('/')
176 addr = parse_address(net[:slash])
177 if net[slash + 1:].isdigit():
178 n = int(net[slash + 1:], 10)
179 mask = (1 << 32) - (1 << 32 - n)
181 mask = parse_address(net[slash + 1:])
182 pats.append((tag, peer, addr & mask, mask))
184 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
185 ## In order to make things vaguely sane, we topologically sort the
186 ## patterns so that more specific patterns are checked first.
187 pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
189 (p == pp and m == (m | mm) and aa == (a & mm)),
191 groups.append((sec, pats))
194 me.testaddr = testaddr
197 ### This will be a configuration file.
200 def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
203 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return str(i)
206 def cmd_showconfig():
207 T.svcinfo('test-addr=%s' % CF.testaddr)
208 def cmd_showgroups():
209 for sec, pats in CF.groups:
211 def cmd_showgroup(g):
212 for s, p in CF.groups:
217 raise T.TripeJobError('unknown-group', g)
218 for t, p, a, m in pats:
220 'target', p or '(default)',
221 'net', '%s/%s' % (straddr(a), strmask(m)))
223 ###--------------------------------------------------------------------------
224 ### Responding to a network up/down event.
228 Return the local IP address used for talking to PEER.
230 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
233 sk.connect((peer, 1))
234 addr, _ = sk.getsockname()
235 addr = parse_address(addr)
247 if _delay is not None:
248 if T._debug: print '# cancel delayed kick'
249 G.source_remove(_delay)
252 def netupdown(upness, reason):
254 Add or kill peers according to whether the network is up or down.
256 UPNESS is true if the network is up, or false if it's down.
259 _kick.put((upness, reason))
261 def delay_netupdown(upness, reason):
266 if T._debug: print '# delayed %s: %s' % (upness, reason)
268 netupdown(upness, reason)
270 if T._debug: print '# delaying %s: %s' % (upness, reason)
271 _delay = G.timeout_add(2000, _func)
275 upness, reason = _kick.get()
276 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
280 ## Make sure the configuration file is up-to-date. Don't worry if we
281 ## can't do anything useful.
284 except Exception, exc:
285 SM.warn('conntrack', 'config-file-error',
286 exc.__class__.__name__, str(exc))
288 ## Find the current list of peers.
291 ## Work out the primary IP address.
293 addr = localaddr(CF.testaddr)
298 if not T._debug: pass
299 elif addr: print '# local address = %s' % straddr(addr)
300 else: print '# offline'
302 ## Now decide what to do.
304 for g, pp in CF.groups:
305 if T._debug: print '# check group %s' % g
307 ## Find out which peer in the group ought to be active.
311 for t, p, a, m in pp:
312 if p is None or not upness:
317 info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
318 t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
319 if upness and ip is None and \
320 ipq is not None and (ipq & m) == a:
321 if T._debug: print '# %s: SELECTED' % info
323 select.append('%s=%s' % (g, t))
324 if t == 'down' or t.startswith('down/'):
331 if T._debug: print '# %s: skipped' % info
333 ## Shut down the wrong ones.
335 if T._debug: print '# peer-map = %r' % map
337 what = map.get(p, 'leave')
340 if T._debug: print '# peer %s: already up' % p
345 except T.TripeError, exc:
346 if exc.args[0] == 'unknown-peer':
347 ## Inherently racy; don't worry about this.
351 if T._debug: print '# peer %s: bring down' % p
354 ## Start the right one if necessary.
355 if want is not None and not found:
358 list(SM.svcsubmit('connect', 'active', want))
359 except T.TripeError, exc:
360 SM.warn('conntrack', 'connect-failed', want, *exc.args)
361 if T._debug: print '# peer %s: bring up' % want
364 ## Commit the changes.
366 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
367 for c in changes: c()
369 ###--------------------------------------------------------------------------
370 ### NetworkManager monitor.
372 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
374 NM_NAME = 'org.freedesktop.NetworkManager'
375 NM_PATH = '/org/freedesktop/NetworkManager'
377 NMCA_IFACE = NM_NAME + '.Connection.Active'
379 NM_STATE_CONNECTED = 3 #obsolete
380 NM_STATE_CONNECTED_LOCAL = 50
381 NM_STATE_CONNECTED_SITE = 60
382 NM_STATE_CONNECTED_GLOBAL = 70
383 NM_CONNSTATES = set([NM_STATE_CONNECTED,
384 NM_STATE_CONNECTED_LOCAL,
385 NM_STATE_CONNECTED_SITE,
386 NM_STATE_CONNECTED_GLOBAL])
388 class NetworkManagerMonitor (object):
390 Watch NetworkManager signals for changes in network state.
393 ## Strategy. There are two kinds of interesting state transitions for us.
394 ## The first one is the global are-we-connected state, which we'll use to
395 ## toggle network upness on a global level. The second is which connection
396 ## has the default route, which we'll use to tweak which peer in the peer
397 ## group is active. The former is most easily tracked using the signal
398 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
399 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
400 ## look for when a new connection gains the default route.
404 nm = bus.get_object(NM_NAME, NM_PATH)
405 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
406 if state in NM_CONNSTATES:
407 netupdown(True, ['nm', 'initially-connected'])
409 netupdown(False, ['nm', 'initially-disconnected'])
410 except D.DBusException, e:
411 if T._debug: print '# exception attaching to network-manager: %s' % e
412 bus.add_signal_receiver(me._nm_state, 'StateChanged',
413 NM_IFACE, NM_NAME, NM_PATH)
414 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
415 NMCA_IFACE, NM_NAME, None)
417 def _nm_state(me, state):
418 if state in NM_CONNSTATES:
419 delay_netupdown(True, ['nm', 'connected'])
421 delay_netupdown(False, ['nm', 'disconnected'])
423 def _nm_connchange(me, props):
424 if props.get('Default', False) or props.get('Default6', False):
425 delay_netupdown(True, ['nm', 'default-connection-change'])
427 ##--------------------------------------------------------------------------
430 CM_NAME = 'net.connman'
432 CM_IFACE = 'net.connman.Manager'
434 class ConnManMonitor (object):
436 Watch ConnMan signls for changes in network state.
439 ## Strategy. Everything seems to be usefully encoded in the `State'
440 ## property. If it's `offline', `idle' or `ready' then we don't expect a
441 ## network connection. During handover from one network to another, the
442 ## property passes through `ready' to `online'.
446 cm = bus.get_object(CM_NAME, CM_PATH)
447 props = cm.GetProperties(dbus_interface = CM_IFACE)
448 state = props['State']
449 netupdown(state == 'online', ['connman', 'initially-%s' % state])
450 except D.DBusException, e:
451 if T._debug: print '# exception attaching to connman: %s' % e
452 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
453 CM_IFACE, CM_NAME, CM_PATH)
455 def _cm_state(me, prop, value):
456 if prop != 'State': return
457 delay_netupdown(value == 'online', ['connman', value])
459 ###--------------------------------------------------------------------------
462 ICD_NAME = 'com.nokia.icd'
463 ICD_PATH = '/com/nokia/icd'
466 class MaemoICdMonitor (object):
468 Watch ICd signals for changes in network state.
471 ## Strategy. ICd only handles one connection at a time in steady state,
472 ## though when switching between connections, it tries to bring the new one
473 ## up before shutting down the old one. This makes life a bit easier than
474 ## it is with NetworkManager. On the other hand, the notifications are
475 ## relative to particular connections only, and the indicator that the old
476 ## connection is down (`IDLE') comes /after/ the new one comes up
477 ## (`CONNECTED'), so we have to remember which one is active.
481 icd = bus.get_object(ICD_NAME, ICD_PATH)
483 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
485 netupdown(True, ['icd', 'initially-connected', iap])
486 except D.DBusException:
488 netupdown(False, ['icd', 'initially-disconnected'])
489 except D.DBusException, e:
490 if T._debug: print '# exception attaching to icd: %s' % e
492 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
495 def _icd_state(me, iap, ty, state, hunoz):
496 if state == 'CONNECTED':
498 delay_netupdown(True, ['icd', 'connected', iap])
499 elif state == 'IDLE' and iap == me._iap:
501 delay_netupdown(False, ['icd', 'idle'])
503 ###--------------------------------------------------------------------------
504 ### D-Bus connection tracking.
506 class DBusMonitor (object):
508 Maintains a connection to the system D-Bus, and watches for signals.
510 If the connection is initially down, or drops for some reason, we retry
511 periodically (every five seconds at the moment). If the connection
512 resurfaces, we reattach the monitors.
517 Initialise the object and try to establish a connection to the bus.
520 me._loop = D.mainloop.glib.DBusGMainLoop()
521 me._state = 'startup'
526 Add a monitor object to watch for signals.
528 MON.attach(BUS) is called, with BUS being the connection to the system
529 bus. MON should query its service's current status and watch for
533 if me._bus is not None:
536 def _reconnect(me, hunoz = None):
538 Start connecting to the bus.
540 If we fail the first time, retry periodically.
542 if me._state == 'startup':
543 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
544 elif me._state == 'connected':
545 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
547 T.aside(SM.notify, 'conntrack', 'dbus-connection',
548 'state=%s' % me._state)
549 me._state == 'reconnecting'
551 if me._try_connect():
552 G.timeout_add_seconds(5, me._try_connect)
554 def _try_connect(me):
556 Actually make a connection attempt.
558 If we succeed, attach the monitors.
561 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
562 if addr == 'SESSION':
563 bus = D.SessionBus(mainloop = me._loop, private = True)
564 elif addr is not None:
565 bus = D.bus.BusConnection(addr, mainloop = me._loop)
567 bus = D.SystemBus(mainloop = me._loop, private = True)
570 except D.DBusException, e:
573 me._state = 'connected'
574 bus.call_on_disconnection(me._reconnect)
575 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
578 ###--------------------------------------------------------------------------
581 class GIOWatcher (object):
583 Monitor I/O events using glib.
585 def __init__(me, conn, mc = G.main_context_default()):
589 def connected(me, sock):
590 me._watch = G.io_add_watch(sock, G.IO_IN,
591 lambda *hunoz: me._conn.receive())
592 def disconnected(me):
593 G.source_remove(me._watch)
596 me._mc.iteration(True)
598 SM.iowatch = GIOWatcher(SM)
602 Service initialization.
604 Add the D-Bus monitor here, because we might send commands off immediately,
605 and we want to make sure the server connection is up.
608 T.Coroutine(kickpeers, name = 'kickpeers').switch()
610 DBM.addmon(NetworkManagerMonitor())
611 DBM.addmon(ConnManMonitor())
612 DBM.addmon(MaemoICdMonitor())
613 G.timeout_add_seconds(30, lambda: (_delay is not None or
614 netupdown(True, ['interval-timer']) or
619 Parse the command-line options.
621 Automatically changes directory to the requested configdir, and turns on
622 debugging. Returns the options object.
624 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
625 version = '%%prog %s' % VERSION)
627 op.add_option('-a', '--admin-socket',
628 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
629 help = 'Select socket to connect to [default %default]')
630 op.add_option('-d', '--directory',
631 metavar = 'DIR', dest = 'dir', default = T.configdir,
632 help = 'Select current diretory [default %default]')
633 op.add_option('-c', '--config',
634 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
635 help = 'Select configuration [default %default]')
636 op.add_option('--daemon', dest = 'daemon',
637 default = False, action = 'store_true',
638 help = 'Become a daemon after successful initialization')
639 op.add_option('--debug', dest = 'debug',
640 default = False, action = 'store_true',
641 help = 'Emit debugging trace information')
642 op.add_option('--startup', dest = 'startup',
643 default = False, action = 'store_true',
644 help = 'Being called as part of the server startup')
646 opts, args = op.parse_args()
647 if args: op.error('no arguments permitted')
649 T._debug = opts.debug
652 ## Service table, for running manually.
653 def cmd_updown(upness):
654 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
655 service_info = [('conntrack', VERSION, {
656 'up': (0, None, '', cmd_updown(True)),
657 'down': (0, None, '', cmd_updown(False)),
658 'show-config': (0, 0, '', cmd_showconfig),
659 'show-groups': (0, 0, '', cmd_showgroups),
660 'show-group': (1, 1, 'GROUP', cmd_showgroup)
663 if __name__ == '__main__':
664 opts = parse_options()
665 CF = Config(opts.conf)
666 T.runservices(opts.tripesock, service_info,
667 init = init, daemon = opts.daemon)
669 ###----- That's all, folks --------------------------------------------------