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 straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
106 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return str(i)
109 ###--------------------------------------------------------------------------
110 ### Parse the configuration file.
112 ## Hmm. Should I try to integrate this with the peers database? It's not a
113 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
114 ## this service are largely going to be satellite notes, I don't think
115 ## scalability's going to be a problem.
117 class Config (object):
119 Represents a configuration file.
121 The most interesting thing is probably the `groups' slot, which stores a
122 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
123 list of (TAG, PEER, ADDR, MASK) triples. The implication is that there
124 should be precisely one peer with a name matching NAME-*, and that it
125 should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such
126 that the host's primary IP address (if PEER is None -- or the IP address it
127 would use for communicating with PEER) is within the network defined by
131 def __init__(me, file):
133 Construct a new Config object, reading the given FILE.
136 me._fwatch = M.FWatch(file)
141 See whether the configuration file has been updated.
143 if me._fwatch.update():
148 Internal function to update the configuration from the underlying file.
151 ## Read the configuration. We have no need of the fancy substitutions,
152 ## so turn them all off.
153 cp = RawConfigParser()
155 if T._debug: print '# reread config'
157 ## Save the test address. Make sure it's vaguely sensible. The default
158 ## is probably good for most cases, in fact, since that address isn't
159 ## actually in use. Note that we never send packets to the test address;
160 ## we just use it to discover routing information.
161 if cp.has_option('DEFAULT', 'test-addr'):
162 testaddr = cp.get('DEFAULT', 'test-addr')
163 S.inet_aton(testaddr)
167 ## Scan the configuration file and build the groups structure.
169 for sec in cp.sections():
171 for tag in cp.options(sec):
172 spec = cp.get(sec, tag).split()
174 ## Parse the entry into peer and network.
181 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
182 ## and MASK is either a dotted-quad or a single integer N indicating
183 ## a mask with N leading ones followed by trailing zeroes.
184 slash = net.index('/')
185 addr = parse_address(net[:slash])
186 if net[slash + 1:].isdigit():
187 n = int(net[slash + 1:], 10)
188 mask = (1 << 32) - (1 << 32 - n)
190 mask = parse_address(net[slash + 1:])
191 pats.append((tag, peer, addr & mask, mask))
193 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
194 ## In order to make things vaguely sane, we topologically sort the
195 ## patterns so that more specific patterns are checked first.
196 pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
198 (p == pp and m == (m | mm) and aa == (a & mm)),
200 groups.append((sec, pats))
203 me.testaddr = testaddr
206 ### This will be a configuration file.
209 def cmd_showconfig():
210 T.svcinfo('test-addr=%s' % CF.testaddr)
211 def cmd_showgroups():
212 for sec, pats in CF.groups:
214 def cmd_showgroup(g):
215 for s, p in CF.groups:
220 raise T.TripeJobError('unknown-group', g)
221 for t, p, a, m in pats:
223 'target', p or '(default)',
224 'net', '%s/%s' % (straddr(a), strmask(m)))
226 ###--------------------------------------------------------------------------
227 ### Responding to a network up/down event.
231 Return the local IP address used for talking to PEER.
233 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
236 sk.connect((peer, 1))
237 addr, _ = sk.getsockname()
238 addr = parse_address(addr)
250 if _delay is not None:
251 if T._debug: print '# cancel delayed kick'
252 G.source_remove(_delay)
255 def netupdown(upness, reason):
257 Add or kill peers according to whether the network is up or down.
259 UPNESS is true if the network is up, or false if it's down.
262 _kick.put((upness, reason))
264 def delay_netupdown(upness, reason):
269 if T._debug: print '# delayed %s: %s' % (upness, reason)
271 netupdown(upness, reason)
273 if T._debug: print '# delaying %s: %s' % (upness, reason)
274 _delay = G.timeout_add(2000, _func)
278 upness, reason = _kick.get()
279 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
283 ## Make sure the configuration file is up-to-date. Don't worry if we
284 ## can't do anything useful.
287 except Exception, exc:
288 SM.warn('conntrack', 'config-file-error',
289 exc.__class__.__name__, str(exc))
291 ## Find the current list of peers.
294 ## Work out the primary IP address.
296 addr = localaddr(CF.testaddr)
301 if not T._debug: pass
302 elif addr: print '# local address = %s' % straddr(addr)
303 else: print '# offline'
305 ## Now decide what to do.
307 for g, pp in CF.groups:
308 if T._debug: print '# check group %s' % g
310 ## Find out which peer in the group ought to be active.
314 for t, p, a, m in pp:
315 if p is None or not upness:
320 info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
321 t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
322 if upness and ip is None and \
323 ipq is not None and (ipq & m) == a:
324 if T._debug: print '# %s: SELECTED' % info
326 select.append('%s=%s' % (g, t))
327 if t == 'down' or t.startswith('down/'):
334 if T._debug: print '# %s: skipped' % info
336 ## Shut down the wrong ones.
338 if T._debug: print '# peer-map = %r' % map
340 what = map.get(p, 'leave')
343 if T._debug: print '# peer %s: already up' % p
348 except T.TripeError, exc:
349 if exc.args[0] == 'unknown-peer':
350 ## Inherently racy; don't worry about this.
354 if T._debug: print '# peer %s: bring down' % p
357 ## Start the right one if necessary.
358 if want is not None and not found:
361 list(SM.svcsubmit('connect', 'active', want))
362 except T.TripeError, exc:
363 SM.warn('conntrack', 'connect-failed', want, *exc.args)
364 if T._debug: print '# peer %s: bring up' % want
367 ## Commit the changes.
369 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
370 for c in changes: c()
372 ###--------------------------------------------------------------------------
373 ### NetworkManager monitor.
375 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
377 NM_NAME = 'org.freedesktop.NetworkManager'
378 NM_PATH = '/org/freedesktop/NetworkManager'
380 NMCA_IFACE = NM_NAME + '.Connection.Active'
382 NM_STATE_CONNECTED = 3 #obsolete
383 NM_STATE_CONNECTED_LOCAL = 50
384 NM_STATE_CONNECTED_SITE = 60
385 NM_STATE_CONNECTED_GLOBAL = 70
386 NM_CONNSTATES = set([NM_STATE_CONNECTED,
387 NM_STATE_CONNECTED_LOCAL,
388 NM_STATE_CONNECTED_SITE,
389 NM_STATE_CONNECTED_GLOBAL])
391 class NetworkManagerMonitor (object):
393 Watch NetworkManager signals for changes in network state.
396 ## Strategy. There are two kinds of interesting state transitions for us.
397 ## The first one is the global are-we-connected state, which we'll use to
398 ## toggle network upness on a global level. The second is which connection
399 ## has the default route, which we'll use to tweak which peer in the peer
400 ## group is active. The former is most easily tracked using the signal
401 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
402 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
403 ## look for when a new connection gains the default route.
407 nm = bus.get_object(NM_NAME, NM_PATH)
408 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
409 if state in NM_CONNSTATES:
410 netupdown(True, ['nm', 'initially-connected'])
412 netupdown(False, ['nm', 'initially-disconnected'])
413 except D.DBusException, e:
414 if T._debug: print '# exception attaching to network-manager: %s' % e
415 bus.add_signal_receiver(me._nm_state, 'StateChanged',
416 NM_IFACE, NM_NAME, NM_PATH)
417 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
418 NMCA_IFACE, NM_NAME, None)
420 def _nm_state(me, state):
421 if state in NM_CONNSTATES:
422 delay_netupdown(True, ['nm', 'connected'])
424 delay_netupdown(False, ['nm', 'disconnected'])
426 def _nm_connchange(me, props):
427 if props.get('Default', False) or props.get('Default6', False):
428 delay_netupdown(True, ['nm', 'default-connection-change'])
430 ##--------------------------------------------------------------------------
433 CM_NAME = 'net.connman'
435 CM_IFACE = 'net.connman.Manager'
437 class ConnManMonitor (object):
439 Watch ConnMan signls for changes in network state.
442 ## Strategy. Everything seems to be usefully encoded in the `State'
443 ## property. If it's `offline', `idle' or `ready' then we don't expect a
444 ## network connection. During handover from one network to another, the
445 ## property passes through `ready' to `online'.
449 cm = bus.get_object(CM_NAME, CM_PATH)
450 props = cm.GetProperties(dbus_interface = CM_IFACE)
451 state = props['State']
452 netupdown(state == 'online', ['connman', 'initially-%s' % state])
453 except D.DBusException, e:
454 if T._debug: print '# exception attaching to connman: %s' % e
455 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
456 CM_IFACE, CM_NAME, CM_PATH)
458 def _cm_state(me, prop, value):
459 if prop != 'State': return
460 delay_netupdown(value == 'online', ['connman', value])
462 ###--------------------------------------------------------------------------
465 ICD_NAME = 'com.nokia.icd'
466 ICD_PATH = '/com/nokia/icd'
469 class MaemoICdMonitor (object):
471 Watch ICd signals for changes in network state.
474 ## Strategy. ICd only handles one connection at a time in steady state,
475 ## though when switching between connections, it tries to bring the new one
476 ## up before shutting down the old one. This makes life a bit easier than
477 ## it is with NetworkManager. On the other hand, the notifications are
478 ## relative to particular connections only, and the indicator that the old
479 ## connection is down (`IDLE') comes /after/ the new one comes up
480 ## (`CONNECTED'), so we have to remember which one is active.
484 icd = bus.get_object(ICD_NAME, ICD_PATH)
486 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
488 netupdown(True, ['icd', 'initially-connected', iap])
489 except D.DBusException:
491 netupdown(False, ['icd', 'initially-disconnected'])
492 except D.DBusException, e:
493 if T._debug: print '# exception attaching to icd: %s' % e
495 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
498 def _icd_state(me, iap, ty, state, hunoz):
499 if state == 'CONNECTED':
501 delay_netupdown(True, ['icd', 'connected', iap])
502 elif state == 'IDLE' and iap == me._iap:
504 delay_netupdown(False, ['icd', 'idle'])
506 ###--------------------------------------------------------------------------
507 ### D-Bus connection tracking.
509 class DBusMonitor (object):
511 Maintains a connection to the system D-Bus, and watches for signals.
513 If the connection is initially down, or drops for some reason, we retry
514 periodically (every five seconds at the moment). If the connection
515 resurfaces, we reattach the monitors.
520 Initialise the object and try to establish a connection to the bus.
523 me._loop = D.mainloop.glib.DBusGMainLoop()
524 me._state = 'startup'
529 Add a monitor object to watch for signals.
531 MON.attach(BUS) is called, with BUS being the connection to the system
532 bus. MON should query its service's current status and watch for
536 if me._bus is not None:
539 def _reconnect(me, hunoz = None):
541 Start connecting to the bus.
543 If we fail the first time, retry periodically.
545 if me._state == 'startup':
546 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
547 elif me._state == 'connected':
548 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
550 T.aside(SM.notify, 'conntrack', 'dbus-connection',
551 'state=%s' % me._state)
552 me._state == 'reconnecting'
554 if me._try_connect():
555 G.timeout_add_seconds(5, me._try_connect)
557 def _try_connect(me):
559 Actually make a connection attempt.
561 If we succeed, attach the monitors.
564 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
565 if addr == 'SESSION':
566 bus = D.SessionBus(mainloop = me._loop, private = True)
567 elif addr is not None:
568 bus = D.bus.BusConnection(addr, mainloop = me._loop)
570 bus = D.SystemBus(mainloop = me._loop, private = True)
573 except D.DBusException, e:
576 me._state = 'connected'
577 bus.call_on_disconnection(me._reconnect)
578 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
581 ###--------------------------------------------------------------------------
584 class GIOWatcher (object):
586 Monitor I/O events using glib.
588 def __init__(me, conn, mc = G.main_context_default()):
592 def connected(me, sock):
593 me._watch = G.io_add_watch(sock, G.IO_IN,
594 lambda *hunoz: me._conn.receive())
595 def disconnected(me):
596 G.source_remove(me._watch)
599 me._mc.iteration(True)
601 SM.iowatch = GIOWatcher(SM)
605 Service initialization.
607 Add the D-Bus monitor here, because we might send commands off immediately,
608 and we want to make sure the server connection is up.
611 T.Coroutine(kickpeers, name = 'kickpeers').switch()
613 DBM.addmon(NetworkManagerMonitor())
614 DBM.addmon(ConnManMonitor())
615 DBM.addmon(MaemoICdMonitor())
616 G.timeout_add_seconds(30, lambda: (_delay is not None or
617 netupdown(True, ['interval-timer']) or
622 Parse the command-line options.
624 Automatically changes directory to the requested configdir, and turns on
625 debugging. Returns the options object.
627 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
628 version = '%%prog %s' % VERSION)
630 op.add_option('-a', '--admin-socket',
631 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
632 help = 'Select socket to connect to [default %default]')
633 op.add_option('-d', '--directory',
634 metavar = 'DIR', dest = 'dir', default = T.configdir,
635 help = 'Select current diretory [default %default]')
636 op.add_option('-c', '--config',
637 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
638 help = 'Select configuration [default %default]')
639 op.add_option('--daemon', dest = 'daemon',
640 default = False, action = 'store_true',
641 help = 'Become a daemon after successful initialization')
642 op.add_option('--debug', dest = 'debug',
643 default = False, action = 'store_true',
644 help = 'Emit debugging trace information')
645 op.add_option('--startup', dest = 'startup',
646 default = False, action = 'store_true',
647 help = 'Being called as part of the server startup')
649 opts, args = op.parse_args()
650 if args: op.error('no arguments permitted')
652 T._debug = opts.debug
655 ## Service table, for running manually.
656 def cmd_updown(upness):
657 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
658 service_info = [('conntrack', VERSION, {
659 'up': (0, None, '', cmd_updown(True)),
660 'down': (0, None, '', cmd_updown(False)),
661 'show-config': (0, 0, '', cmd_showconfig),
662 'show-groups': (0, 0, '', cmd_showgroups),
663 'show-group': (1, 1, 'GROUP', cmd_showgroup)
666 if __name__ == '__main__':
667 opts = parse_options()
668 CF = Config(opts.conf)
669 T.runservices(opts.tripesock, service_info,
670 init = init, daemon = opts.daemon)
672 ###----- That's all, folks --------------------------------------------------