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 ### Parse the configuration file.
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.
105 class Config (object):
107 Represents a configuration file.
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
119 def __init__(me, file):
121 Construct a new Config object, reading the given FILE.
124 me._fwatch = M.FWatch(file)
129 See whether the configuration file has been updated.
131 if me._fwatch.update():
136 Internal function to update the configuration from the underlying file.
139 ## Read the configuration. We have no need of the fancy substitutions,
140 ## so turn them all off.
141 cp = RawConfigParser()
143 if T._debug: print '# reread config'
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)
155 ## Scan the configuration file and build the groups structure.
157 for sec in cp.sections():
159 for tag in cp.options(sec):
160 spec = cp.get(sec, tag).split()
162 ## Parse the entry into peer and network.
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[slash + 1:].isdigit():
175 n = int(net[slash + 1:], 10)
176 mask = (1 << 32) - (1 << 32 - n)
178 mask, = unpack('>L', S.inet_aton(net[slash + 1:]))
179 pats.append((tag, peer, addr & mask, mask))
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): \
186 (p == pp and m == (m | mm) and aa == (a & mm)),
188 groups.append((sec, pats))
191 me.testaddr = testaddr
194 ### This will be a configuration file.
197 def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
200 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return i
203 def cmd_showconfig():
204 T.svcinfo('test-addr=%s' % CF.testaddr)
205 def cmd_showgroups():
206 for sec, pats in CF.groups:
208 def cmd_showgroup(g):
209 for s, p in CF.groups:
214 raise T.TripeJobError('unknown-group', g)
215 for t, p, a, m in pats:
217 'target', p or '(default)',
218 'net', '%s/%s' % (straddr(a), strmask(m)))
220 ###--------------------------------------------------------------------------
221 ### Responding to a network up/down event.
225 Return the local IP address used for talking to PEER.
227 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
230 sk.connect((peer, 1))
231 addr, _ = sk.getsockname()
232 addr, = unpack('>L', S.inet_aton(addr))
244 if _delay is not None:
245 if T._debug: print '# cancel delayed kick'
246 G.source_remove(_delay)
249 def netupdown(upness, reason):
251 Add or kill peers according to whether the network is up or down.
253 UPNESS is true if the network is up, or false if it's down.
256 _kick.put((upness, reason))
258 def delay_netupdown(upness, reason):
263 if T._debug: print '# delayed %s: %s' % (upness, reason)
265 netupdown(upness, reason)
267 if T._debug: print '# delaying %s: %s' % (upness, reason)
268 _delay = G.timeout_add(2000, _func)
272 upness, reason = _kick.get()
273 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
277 ## Make sure the configuration file is up-to-date. Don't worry if we
278 ## can't do anything useful.
281 except Exception, exc:
282 SM.warn('conntrack', 'config-file-error',
283 exc.__class__.__name__, str(exc))
285 ## Find the current list of peers.
288 ## Work out the primary IP address.
290 addr = localaddr(CF.testaddr)
295 if not T._debug: pass
296 elif addr: print '# local address = %s' % straddr(addr)
297 else: print '# offline'
299 ## Now decide what to do.
301 for g, pp in CF.groups:
302 if T._debug: print '# check group %s' % g
304 ## Find out which peer in the group ought to be active.
308 for t, p, a, m in pp:
309 if p is None or not upness:
314 info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
315 t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
316 if upness and ip is None and \
317 ipq is not None and (ipq & m) == a:
318 if T._debug: print '# %s: SELECTED' % info
320 select.append('%s=%s' % (g, t))
321 if t == 'down' or t.startswith('down/'):
328 if T._debug: print '# %s: skipped' % info
330 ## Shut down the wrong ones.
332 if T._debug: print '# peer-map = %r' % map
334 what = map.get(p, 'leave')
337 if T._debug: print '# peer %s: already up' % p
342 except T.TripeError, exc:
343 if exc.args[0] == 'unknown-peer':
344 ## Inherently racy; don't worry about this.
348 if T._debug: print '# peer %s: bring down' % p
351 ## Start the right one if necessary.
352 if want is not None and not found:
355 list(SM.svcsubmit('connect', 'active', want))
356 except T.TripeError, exc:
357 SM.warn('conntrack', 'connect-failed', want, *exc.args)
358 if T._debug: print '# peer %s: bring up' % want
361 ## Commit the changes.
363 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
364 for c in changes: c()
366 ###--------------------------------------------------------------------------
367 ### NetworkManager monitor.
369 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
371 NM_NAME = 'org.freedesktop.NetworkManager'
372 NM_PATH = '/org/freedesktop/NetworkManager'
374 NMCA_IFACE = NM_NAME + '.Connection.Active'
376 NM_STATE_CONNECTED = 3 #obsolete
377 NM_STATE_CONNECTED_LOCAL = 50
378 NM_STATE_CONNECTED_SITE = 60
379 NM_STATE_CONNECTED_GLOBAL = 70
380 NM_CONNSTATES = set([NM_STATE_CONNECTED,
381 NM_STATE_CONNECTED_LOCAL,
382 NM_STATE_CONNECTED_SITE,
383 NM_STATE_CONNECTED_GLOBAL])
385 class NetworkManagerMonitor (object):
387 Watch NetworkManager signals for changes in network state.
390 ## Strategy. There are two kinds of interesting state transitions for us.
391 ## The first one is the global are-we-connected state, which we'll use to
392 ## toggle network upness on a global level. The second is which connection
393 ## has the default route, which we'll use to tweak which peer in the peer
394 ## group is active. The former is most easily tracked using the signal
395 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
396 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
397 ## look for when a new connection gains the default route.
401 nm = bus.get_object(NM_NAME, NM_PATH)
402 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
403 if state in NM_CONNSTATES:
404 netupdown(True, ['nm', 'initially-connected'])
406 netupdown(False, ['nm', 'initially-disconnected'])
407 except D.DBusException, e:
408 if T._debug: print '# exception attaching to network-manager: %s' % e
409 bus.add_signal_receiver(me._nm_state, 'StateChanged',
410 NM_IFACE, NM_NAME, NM_PATH)
411 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
412 NMCA_IFACE, NM_NAME, None)
414 def _nm_state(me, state):
415 if state in NM_CONNSTATES:
416 delay_netupdown(True, ['nm', 'connected'])
418 delay_netupdown(False, ['nm', 'disconnected'])
420 def _nm_connchange(me, props):
421 if props.get('Default', False) or props.get('Default6', False):
422 delay_netupdown(True, ['nm', 'default-connection-change'])
424 ##--------------------------------------------------------------------------
427 CM_NAME = 'net.connman'
429 CM_IFACE = 'net.connman.Manager'
431 class ConnManMonitor (object):
433 Watch ConnMan signls for changes in network state.
436 ## Strategy. Everything seems to be usefully encoded in the `State'
437 ## property. If it's `offline', `idle' or `ready' then we don't expect a
438 ## network connection. During handover from one network to another, the
439 ## property passes through `ready' to `online'.
443 cm = bus.get_object(CM_NAME, CM_PATH)
444 props = cm.GetProperties(dbus_interface = CM_IFACE)
445 state = props['State']
446 netupdown(state == 'online', ['connman', 'initially-%s' % state])
447 except D.DBusException, e:
448 if T._debug: print '# exception attaching to connman: %s' % e
449 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
450 CM_IFACE, CM_NAME, CM_PATH)
452 def _cm_state(me, prop, value):
453 if prop != 'State': return
454 delay_netupdown(value == 'online', ['connman', value])
456 ###--------------------------------------------------------------------------
459 ICD_NAME = 'com.nokia.icd'
460 ICD_PATH = '/com/nokia/icd'
463 class MaemoICdMonitor (object):
465 Watch ICd signals for changes in network state.
468 ## Strategy. ICd only handles one connection at a time in steady state,
469 ## though when switching between connections, it tries to bring the new one
470 ## up before shutting down the old one. This makes life a bit easier than
471 ## it is with NetworkManager. On the other hand, the notifications are
472 ## relative to particular connections only, and the indicator that the old
473 ## connection is down (`IDLE') comes /after/ the new one comes up
474 ## (`CONNECTED'), so we have to remember which one is active.
478 icd = bus.get_object(ICD_NAME, ICD_PATH)
480 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
482 netupdown(True, ['icd', 'initially-connected', iap])
483 except D.DBusException:
485 netupdown(False, ['icd', 'initially-disconnected'])
486 except D.DBusException, e:
487 if T._debug: print '# exception attaching to icd: %s' % e
489 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
492 def _icd_state(me, iap, ty, state, hunoz):
493 if state == 'CONNECTED':
495 delay_netupdown(True, ['icd', 'connected', iap])
496 elif state == 'IDLE' and iap == me._iap:
498 delay_netupdown(False, ['icd', 'idle'])
500 ###--------------------------------------------------------------------------
501 ### D-Bus connection tracking.
503 class DBusMonitor (object):
505 Maintains a connection to the system D-Bus, and watches for signals.
507 If the connection is initially down, or drops for some reason, we retry
508 periodically (every five seconds at the moment). If the connection
509 resurfaces, we reattach the monitors.
514 Initialise the object and try to establish a connection to the bus.
517 me._loop = D.mainloop.glib.DBusGMainLoop()
518 me._state = 'startup'
523 Add a monitor object to watch for signals.
525 MON.attach(BUS) is called, with BUS being the connection to the system
526 bus. MON should query its service's current status and watch for
530 if me._bus is not None:
533 def _reconnect(me, hunoz = None):
535 Start connecting to the bus.
537 If we fail the first time, retry periodically.
539 if me._state == 'startup':
540 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
541 elif me._state == 'connected':
542 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
544 T.aside(SM.notify, 'conntrack', 'dbus-connection',
545 'state=%s' % me._state)
546 me._state == 'reconnecting'
548 if me._try_connect():
549 G.timeout_add_seconds(5, me._try_connect)
551 def _try_connect(me):
553 Actually make a connection attempt.
555 If we succeed, attach the monitors.
558 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
559 if addr == 'SESSION':
560 bus = D.SessionBus(mainloop = me._loop, private = True)
561 elif addr is not None:
562 bus = D.bus.BusConnection(addr, mainloop = me._loop)
564 bus = D.SystemBus(mainloop = me._loop, private = True)
567 except D.DBusException, e:
570 me._state = 'connected'
571 bus.call_on_disconnection(me._reconnect)
572 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
575 ###--------------------------------------------------------------------------
578 class GIOWatcher (object):
580 Monitor I/O events using glib.
582 def __init__(me, conn, mc = G.main_context_default()):
586 def connected(me, sock):
587 me._watch = G.io_add_watch(sock, G.IO_IN,
588 lambda *hunoz: me._conn.receive())
589 def disconnected(me):
590 G.source_remove(me._watch)
593 me._mc.iteration(True)
595 SM.iowatch = GIOWatcher(SM)
599 Service initialization.
601 Add the D-Bus monitor here, because we might send commands off immediately,
602 and we want to make sure the server connection is up.
605 T.Coroutine(kickpeers, name = 'kickpeers').switch()
607 DBM.addmon(NetworkManagerMonitor())
608 DBM.addmon(ConnManMonitor())
609 DBM.addmon(MaemoICdMonitor())
610 G.timeout_add_seconds(30, lambda: (_delay is not None or
611 netupdown(True, ['interval-timer']) or
616 Parse the command-line options.
618 Automatically changes directory to the requested configdir, and turns on
619 debugging. Returns the options object.
621 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
622 version = '%%prog %s' % VERSION)
624 op.add_option('-a', '--admin-socket',
625 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
626 help = 'Select socket to connect to [default %default]')
627 op.add_option('-d', '--directory',
628 metavar = 'DIR', dest = 'dir', default = T.configdir,
629 help = 'Select current diretory [default %default]')
630 op.add_option('-c', '--config',
631 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
632 help = 'Select configuration [default %default]')
633 op.add_option('--daemon', dest = 'daemon',
634 default = False, action = 'store_true',
635 help = 'Become a daemon after successful initialization')
636 op.add_option('--debug', dest = 'debug',
637 default = False, action = 'store_true',
638 help = 'Emit debugging trace information')
639 op.add_option('--startup', dest = 'startup',
640 default = False, action = 'store_true',
641 help = 'Being called as part of the server startup')
643 opts, args = op.parse_args()
644 if args: op.error('no arguments permitted')
646 T._debug = opts.debug
649 ## Service table, for running manually.
650 def cmd_updown(upness):
651 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
652 service_info = [('conntrack', VERSION, {
653 'up': (0, None, '', cmd_updown(True)),
654 'down': (0, None, '', cmd_updown(False)),
655 'show-config': (0, 0, '', cmd_showconfig),
656 'show-groups': (0, 0, '', cmd_showgroups),
657 'show-group': (1, 1, 'GROUP', cmd_showgroup)
660 if __name__ == '__main__':
661 opts = parse_options()
662 CF = Config(opts.conf)
663 T.runservices(opts.tripesock, service_info,
664 init = init, daemon = opts.daemon)
666 ###----- That's all, folks --------------------------------------------------