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)),
237 groups.append((sec, pats))
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 sec, pats in CF.groups:
251 def cmd_showgroup(g):
252 for s, p in CF.groups:
257 raise T.TripeJobError('unknown-group', g)
260 'target', p and str(p) or '(default)',
263 ###--------------------------------------------------------------------------
264 ### Responding to a network up/down event.
268 Return the local IP address used for talking to PEER.
270 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
273 sk.connect(peer.sockaddr(1))
274 addr = sk.getsockname()
275 return InetAddress.from_sockaddr(addr)[0]
286 if _delay is not None:
287 if T._debug: print '# cancel delayed kick'
288 G.source_remove(_delay)
291 def netupdown(upness, reason):
293 Add or kill peers according to whether the network is up or down.
295 UPNESS is true if the network is up, or false if it's down.
298 _kick.put((upness, reason))
300 def delay_netupdown(upness, reason):
305 if T._debug: print '# delayed %s: %s' % (upness, reason)
307 netupdown(upness, reason)
309 if T._debug: print '# delaying %s: %s' % (upness, reason)
310 _delay = G.timeout_add(2000, _func)
314 upness, reason = _kick.get()
315 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
319 ## Make sure the configuration file is up-to-date. Don't worry if we
320 ## can't do anything useful.
323 except Exception, exc:
324 SM.warn('conntrack', 'config-file-error',
325 exc.__class__.__name__, str(exc))
327 ## Find the current list of peers.
330 ## Work out the primary IP address.
332 addr = localaddr(CF.testaddr)
337 if not T._debug: pass
338 elif addr: print '# local address = %s' % straddr(addr)
339 else: print '# offline'
341 ## Now decide what to do.
343 for g, pp in CF.groups:
344 if T._debug: print '# check group %s' % g
346 ## Find out which peer in the group ought to be active.
351 if p is None or not upness:
356 info = 'peer=%s; target=%s; net=%s; local=%s' % (
357 t, p or '(default)', n, straddr(ipq))
358 if upness and ip is None and \
359 ipq is not None and ipq.withinp(n):
360 if T._debug: print '# %s: SELECTED' % info
362 select.append('%s=%s' % (g, t))
363 if t == 'down' or t.startswith('down/'):
370 if T._debug: print '# %s: skipped' % info
372 ## Shut down the wrong ones.
374 if T._debug: print '# peer-map = %r' % map
376 what = map.get(p, 'leave')
379 if T._debug: print '# peer %s: already up' % p
384 except T.TripeError, exc:
385 if exc.args[0] == 'unknown-peer':
386 ## Inherently racy; don't worry about this.
390 if T._debug: print '# peer %s: bring down' % p
393 ## Start the right one if necessary.
394 if want is not None and not found:
397 list(SM.svcsubmit('connect', 'active', want))
398 except T.TripeError, exc:
399 SM.warn('conntrack', 'connect-failed', want, *exc.args)
400 if T._debug: print '# peer %s: bring up' % want
403 ## Commit the changes.
405 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
406 for c in changes: c()
408 ###--------------------------------------------------------------------------
409 ### NetworkManager monitor.
411 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
413 NM_NAME = 'org.freedesktop.NetworkManager'
414 NM_PATH = '/org/freedesktop/NetworkManager'
416 NMCA_IFACE = NM_NAME + '.Connection.Active'
418 NM_STATE_CONNECTED = 3 #obsolete
419 NM_STATE_CONNECTED_LOCAL = 50
420 NM_STATE_CONNECTED_SITE = 60
421 NM_STATE_CONNECTED_GLOBAL = 70
422 NM_CONNSTATES = set([NM_STATE_CONNECTED,
423 NM_STATE_CONNECTED_LOCAL,
424 NM_STATE_CONNECTED_SITE,
425 NM_STATE_CONNECTED_GLOBAL])
427 class NetworkManagerMonitor (object):
429 Watch NetworkManager signals for changes in network state.
432 ## Strategy. There are two kinds of interesting state transitions for us.
433 ## The first one is the global are-we-connected state, which we'll use to
434 ## toggle network upness on a global level. The second is which connection
435 ## has the default route, which we'll use to tweak which peer in the peer
436 ## group is active. The former is most easily tracked using the signal
437 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
438 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
439 ## look for when a new connection gains the default route.
443 nm = bus.get_object(NM_NAME, NM_PATH)
444 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
445 if state in NM_CONNSTATES:
446 netupdown(True, ['nm', 'initially-connected'])
448 netupdown(False, ['nm', 'initially-disconnected'])
449 except D.DBusException, e:
450 if T._debug: print '# exception attaching to network-manager: %s' % e
451 bus.add_signal_receiver(me._nm_state, 'StateChanged',
452 NM_IFACE, NM_NAME, NM_PATH)
453 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
454 NMCA_IFACE, NM_NAME, None)
456 def _nm_state(me, state):
457 if state in NM_CONNSTATES:
458 delay_netupdown(True, ['nm', 'connected'])
460 delay_netupdown(False, ['nm', 'disconnected'])
462 def _nm_connchange(me, props):
463 if props.get('Default', False) or props.get('Default6', False):
464 delay_netupdown(True, ['nm', 'default-connection-change'])
466 ##--------------------------------------------------------------------------
469 CM_NAME = 'net.connman'
471 CM_IFACE = 'net.connman.Manager'
473 class ConnManMonitor (object):
475 Watch ConnMan signls for changes in network state.
478 ## Strategy. Everything seems to be usefully encoded in the `State'
479 ## property. If it's `offline', `idle' or `ready' then we don't expect a
480 ## network connection. During handover from one network to another, the
481 ## property passes through `ready' to `online'.
485 cm = bus.get_object(CM_NAME, CM_PATH)
486 props = cm.GetProperties(dbus_interface = CM_IFACE)
487 state = props['State']
488 netupdown(state == 'online', ['connman', 'initially-%s' % state])
489 except D.DBusException, e:
490 if T._debug: print '# exception attaching to connman: %s' % e
491 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
492 CM_IFACE, CM_NAME, CM_PATH)
494 def _cm_state(me, prop, value):
495 if prop != 'State': return
496 delay_netupdown(value == 'online', ['connman', value])
498 ###--------------------------------------------------------------------------
501 ICD_NAME = 'com.nokia.icd'
502 ICD_PATH = '/com/nokia/icd'
505 class MaemoICdMonitor (object):
507 Watch ICd signals for changes in network state.
510 ## Strategy. ICd only handles one connection at a time in steady state,
511 ## though when switching between connections, it tries to bring the new one
512 ## up before shutting down the old one. This makes life a bit easier than
513 ## it is with NetworkManager. On the other hand, the notifications are
514 ## relative to particular connections only, and the indicator that the old
515 ## connection is down (`IDLE') comes /after/ the new one comes up
516 ## (`CONNECTED'), so we have to remember which one is active.
520 icd = bus.get_object(ICD_NAME, ICD_PATH)
522 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
524 netupdown(True, ['icd', 'initially-connected', iap])
525 except D.DBusException:
527 netupdown(False, ['icd', 'initially-disconnected'])
528 except D.DBusException, e:
529 if T._debug: print '# exception attaching to icd: %s' % e
531 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
534 def _icd_state(me, iap, ty, state, hunoz):
535 if state == 'CONNECTED':
537 delay_netupdown(True, ['icd', 'connected', iap])
538 elif state == 'IDLE' and iap == me._iap:
540 delay_netupdown(False, ['icd', 'idle'])
542 ###--------------------------------------------------------------------------
543 ### D-Bus connection tracking.
545 class DBusMonitor (object):
547 Maintains a connection to the system D-Bus, and watches for signals.
549 If the connection is initially down, or drops for some reason, we retry
550 periodically (every five seconds at the moment). If the connection
551 resurfaces, we reattach the monitors.
556 Initialise the object and try to establish a connection to the bus.
559 me._loop = D.mainloop.glib.DBusGMainLoop()
560 me._state = 'startup'
565 Add a monitor object to watch for signals.
567 MON.attach(BUS) is called, with BUS being the connection to the system
568 bus. MON should query its service's current status and watch for
572 if me._bus is not None:
575 def _reconnect(me, hunoz = None):
577 Start connecting to the bus.
579 If we fail the first time, retry periodically.
581 if me._state == 'startup':
582 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
583 elif me._state == 'connected':
584 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
586 T.aside(SM.notify, 'conntrack', 'dbus-connection',
587 'state=%s' % me._state)
588 me._state == 'reconnecting'
590 if me._try_connect():
591 G.timeout_add_seconds(5, me._try_connect)
593 def _try_connect(me):
595 Actually make a connection attempt.
597 If we succeed, attach the monitors.
600 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
601 if addr == 'SESSION':
602 bus = D.SessionBus(mainloop = me._loop, private = True)
603 elif addr is not None:
604 bus = D.bus.BusConnection(addr, mainloop = me._loop)
606 bus = D.SystemBus(mainloop = me._loop, private = True)
609 except D.DBusException, e:
612 me._state = 'connected'
613 bus.call_on_disconnection(me._reconnect)
614 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
617 ###--------------------------------------------------------------------------
620 class GIOWatcher (object):
622 Monitor I/O events using glib.
624 def __init__(me, conn, mc = G.main_context_default()):
628 def connected(me, sock):
629 me._watch = G.io_add_watch(sock, G.IO_IN,
630 lambda *hunoz: me._conn.receive())
631 def disconnected(me):
632 G.source_remove(me._watch)
635 me._mc.iteration(True)
637 SM.iowatch = GIOWatcher(SM)
641 Service initialization.
643 Add the D-Bus monitor here, because we might send commands off immediately,
644 and we want to make sure the server connection is up.
647 T.Coroutine(kickpeers, name = 'kickpeers').switch()
649 DBM.addmon(NetworkManagerMonitor())
650 DBM.addmon(ConnManMonitor())
651 DBM.addmon(MaemoICdMonitor())
652 G.timeout_add_seconds(30, lambda: (_delay is not None or
653 netupdown(True, ['interval-timer']) or
658 Parse the command-line options.
660 Automatically changes directory to the requested configdir, and turns on
661 debugging. Returns the options object.
663 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
664 version = '%%prog %s' % VERSION)
666 op.add_option('-a', '--admin-socket',
667 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
668 help = 'Select socket to connect to [default %default]')
669 op.add_option('-d', '--directory',
670 metavar = 'DIR', dest = 'dir', default = T.configdir,
671 help = 'Select current diretory [default %default]')
672 op.add_option('-c', '--config',
673 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
674 help = 'Select configuration [default %default]')
675 op.add_option('--daemon', dest = 'daemon',
676 default = False, action = 'store_true',
677 help = 'Become a daemon after successful initialization')
678 op.add_option('--debug', dest = 'debug',
679 default = False, action = 'store_true',
680 help = 'Emit debugging trace information')
681 op.add_option('--startup', dest = 'startup',
682 default = False, action = 'store_true',
683 help = 'Being called as part of the server startup')
685 opts, args = op.parse_args()
686 if args: op.error('no arguments permitted')
688 T._debug = opts.debug
691 ## Service table, for running manually.
692 def cmd_updown(upness):
693 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
694 service_info = [('conntrack', VERSION, {
695 'up': (0, None, '', cmd_updown(True)),
696 'down': (0, None, '', cmd_updown(False)),
697 'show-config': (0, 0, '', cmd_showconfig),
698 'show-groups': (0, 0, '', cmd_showgroups),
699 'show-group': (1, 1, 'GROUP', cmd_showgroup)
702 if __name__ == '__main__':
703 opts = parse_options()
704 CF = Config(opts.conf)
705 T.runservices(opts.tripesock, service_info,
706 init = init, daemon = opts.daemon)
708 ###----- That's all, folks --------------------------------------------------