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
40 for i in ['mainloop', 'mainloop.glib']:
41 __import__('dbus.%s' % i)
42 try: from gi.repository import GLib as G
43 except ImportError: import gobject as G
44 from struct import pack, unpack
45 from cStringIO import StringIO
48 ##__import__('rmcr').__debug = True
50 ###--------------------------------------------------------------------------
53 class struct (object):
54 """A simple container object."""
55 def __init__(me, **kw):
56 me.__dict__.update(kw)
60 for ch in s: n = 256*n + ord(ch)
63 def storeb(n, wd = None):
64 if wd is None: wd = n.bit_length()
66 for i in xrange((wd - 1)&-8, -8, -8): s.write(chr((n >> i)&0xff))
69 ###--------------------------------------------------------------------------
70 ### Address manipulation.
72 ### I think this is the most demanding application, in terms of address
73 ### hacking, in the entire TrIPE suite. At least we don't have to do it in
76 class BaseAddress (object):
77 def __init__(me, addrstr, maskstr = None):
81 elif maskstr.isdigit():
82 me.mask = (1 << me.NBITS) - (1 << me.NBITS - int(maskstr))
86 raise ValueError('network contains bits set beyond mask')
87 def _addrstr_to_int(me, addrstr):
88 try: return loadb(S.inet_pton(me.AF, addrstr))
89 except S.error: raise ValueError('bad address syntax')
90 def _int_to_addrstr(me, n):
91 return S.inet_ntop(me.AF, storeb(me.addr, me.NBITS))
92 def _setmask(me, maskstr):
93 raise ValueError('only prefix masked supported')
95 raise ValueError('only prefix masked supported')
96 def sockaddr(me, port = 0):
97 if me.mask != -1: raise ValueError('not a simple address')
98 return me._sockaddr(port)
100 addrstr = me._addrstr()
104 inv = me.mask ^ ((1 << me.NBITS) - 1)
105 if (inv&(inv + 1)) == 0:
106 return '%s/%d' % (addrstr, me.NBITS - inv.bit_length())
108 return '%s/%s' % (addrstr, me._maskstr())
109 def withinp(me, net):
110 if type(net) != type(me): return False
111 if (me.mask&net.mask) != net.mask: return False
112 if (me.addr ^ net.addr)&net.mask: return False
113 return me._withinp(net)
115 if type(me) != type(other): return False
116 if me.mask != other.mask: return False
117 if me.addr != other.addr: return False
119 def _withinp(me, net):
124 class InetAddress (BaseAddress):
128 def _addrstr_to_int(me, addrstr):
129 try: return loadb(S.inet_aton(addrstr))
130 except S.error: raise ValueError('bad address syntax')
131 def _setaddr(me, addrstr):
132 me.addr = me._addrstr_to_int(addrstr)
133 def _setmask(me, maskstr):
134 me.mask = me._addrstr_to_int(maskstr)
136 return me._int_to_addrstr(me.addr)
138 return me._int_to_addrstr(me.mask)
139 def _sockaddr(me, port = 0):
140 return (me._addrstr(), port)
142 def from_sockaddr(cls, sa):
143 addr, port = (lambda a, p: (a, p))(*sa)
144 return cls(addr), port
146 def parse_address(addrstr, maskstr = None):
147 return InetAddress(addrstr, maskstr)
149 def parse_net(netstr):
150 try: sl = netstr.index('/')
151 except ValueError: raise ValueError('missing mask')
152 return parse_address(netstr[:sl], netstr[sl + 1:])
154 def straddr(a): return a is None and '#<none>' or str(a)
156 ###--------------------------------------------------------------------------
157 ### Parse the configuration file.
159 ## Hmm. Should I try to integrate this with the peers database? It's not a
160 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
161 ## this service are largely going to be satellite notes, I don't think
162 ## scalability's going to be a problem.
164 TESTADDRS = [InetAddress('1.2.3.4')]
167 ('COMMENT', RX.compile(r'^\s*($|[;#])')),
168 ('GRPHDR', RX.compile(r'^\s*\[(.*)\]\s*$')),
169 ('ASSGN', RX.compile(r'\s*([\w.-]+)\s*[:=]\s*(|\S|\S.*\S)\s*$'))]
171 class ConfigError (Exception):
172 def __init__(me, file, lno, msg):
177 return '%s:%d: %s' % (me.file, me.lno, me.msg)
179 class Config (object):
181 Represents a configuration file.
183 The most interesting thing is probably the `groups' slot, which stores a
184 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
185 list of (TAG, PEER, NETS) triples. The implication is that there should be
186 precisely one peer from the set, and that it should be named TAG, where
187 (TAG, PEER, NETS) is the first triple such that the host's primary IP
188 address (if PEER is None -- or the IP address it would use for
189 communicating with PEER) is within one of the networks defined by NETS.
192 def __init__(me, file):
194 Construct a new Config object, reading the given FILE.
197 me._fwatch = M.FWatch(file)
202 See whether the configuration file has been updated.
204 if me._fwatch.update():
209 Internal function to update the configuration from the underlying file.
212 if T._debug: print '# reread config'
220 ## Open the file and start reading.
221 with open(me._file) as f:
225 for tag, rx in CONFSYNTAX:
229 raise ConfigError(me._file, lno, 'failed to parse line: %r' % line)
232 ## A comment. Ignore it and hope it goes away.
236 elif tag == 'GRPHDR':
237 ## A group header. Flush the old group and start a new one.
240 if grpname is not None: groups[grpname] = grplist
241 if newname in groups:
242 raise ConfigError(me._file, lno,
243 "duplicate group name `%s'" % newname)
248 ## An assignment. Deal with it.
249 name, value = m.group(1), m.group(2)
252 ## We're outside of any group, so this is a global configuration
255 if name == 'test-addr':
256 for astr in value.split():
258 a = parse_address(astr)
260 raise ConfigError(me._file, lno,
261 "invalid IP address `%s': %s" %
263 if a.AF in testaddrs:
264 raise ConfigError(me._file, lno,
265 'duplicate %s test-address' % a.AFNAME)
268 raise ConfigError(me._file, lno,
269 "unknown global option `%s'" % name)
272 ## Parse a pattern and add it to the group.
276 ## Check for an explicit target address.
277 if i >= len(spec) or spec[i].find('/') >= 0:
282 peer = parse_address(spec[i])
284 raise ConfigError(me._file, lno,
285 "invalid IP address `%s': %s" %
290 ## Parse the list of local networks.
294 net = parse_net(spec[i])
296 raise ConfigError(me._file, lno,
297 "invalid IP network `%s': %s" %
303 raise ConfigError(me._file, lno, 'no networks defined')
305 ## Make sure that the addresses are consistent.
310 raise ConfigError(me._file, lno,
311 "net %s doesn't match" % net)
313 ## Add this entry to the list.
314 grplist.append((name, peer, nets))
316 ## Fill in the default test addresses if necessary.
317 for a in TESTADDRS: testaddrs.setdefault(a.AF, a)
320 if grpname is not None: groups[grpname] = grplist
321 me.testaddrs = testaddrs
324 ### This will be a configuration file.
327 def cmd_showconfig():
328 T.svcinfo('test-addr=%s' %
330 for a in sorted(CF.testaddrs.itervalues(),
331 key = lambda a: a.AFNAME)))
332 def cmd_showgroups():
333 for g in sorted(CF.groups.iterkeys()):
335 def cmd_showgroup(g):
336 try: pats = CF.groups[g]
337 except KeyError: raise T.TripeJobError('unknown-group', g)
338 for t, p, nn in pats:
340 'target', p and str(p) or '(default)',
341 'net', ' '.join(map(str, nn)))
343 ###--------------------------------------------------------------------------
344 ### Responding to a network up/down event.
348 Return the local IP address used for talking to PEER.
350 sk = S.socket(peer.AF, S.SOCK_DGRAM)
353 sk.connect(peer.sockaddr(1))
354 addr = sk.getsockname()
355 return type(peer).from_sockaddr(addr)[0]
366 if _delay is not None:
367 if T._debug: print '# cancel delayed kick'
368 G.source_remove(_delay)
371 def netupdown(upness, reason):
373 Add or kill peers according to whether the network is up or down.
375 UPNESS is true if the network is up, or false if it's down.
378 _kick.put((upness, reason))
380 def delay_netupdown(upness, reason):
385 if T._debug: print '# delayed %s: %s' % (upness, reason)
387 netupdown(upness, reason)
389 if T._debug: print '# delaying %s: %s' % (upness, reason)
390 _delay = G.timeout_add(2000, _func)
394 upness, reason = _kick.get()
395 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
399 ## Make sure the configuration file is up-to-date. Don't worry if we
400 ## can't do anything useful.
403 except Exception, exc:
404 SM.warn('conntrack', 'config-file-error',
405 exc.__class__.__name__, str(exc))
407 ## Find the current list of peers.
410 ## Work out the primary IP addresses.
413 for af, remote in CF.testaddrs.iteritems():
414 local = localaddr(remote)
415 if local is not None: locals[af] = local
416 if not locals: upness = False
417 if not T._debug: pass
418 elif not locals: print '# offline'
420 for local in locals.itervalues():
421 print '# local %s address = %s' % (local.AFNAME, local)
423 ## Now decide what to do.
425 for g, pp in CF.groups.iteritems():
426 if T._debug: print '# check group %s' % g
428 ## Find out which peer in the group ought to be active.
434 if p is None or not upness: ip = locals.get(af)
435 else: ip = localaddr(p)
437 info = 'peer = %s; target = %s; nets = %s; local = %s' % (
438 t, p or '(default)', ', '.join(map(str, nn)), straddr(ip))
439 if upness and not matchp and \
440 ip is not None and any(ip.withinp(n) for n in nn):
441 if T._debug: print '# %s: SELECTED' % info
443 select.append('%s=%s' % (g, t))
444 if t == 'down' or t.startswith('down/'): want = None
449 if T._debug: print '# %s: skipped' % info
451 ## Shut down the wrong ones.
453 if T._debug: print '# peer-map = %r' % statemap
455 what = statemap.get(p, 'leave')
458 if T._debug: print '# peer %s: already up' % p
463 except T.TripeError, exc:
464 if exc.args[0] == 'unknown-peer':
465 ## Inherently racy; don't worry about this.
469 if T._debug: print '# peer %s: bring down' % p
472 ## Start the right one if necessary.
473 if want is not None and not found:
476 list(SM.svcsubmit('connect', 'active', want))
477 except T.TripeError, exc:
478 SM.warn('conntrack', 'connect-failed', want, *exc.args)
479 if T._debug: print '# peer %s: bring up' % want
482 ## Commit the changes.
484 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
485 for c in changes: c()
487 ###--------------------------------------------------------------------------
488 ### NetworkManager monitor.
490 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
492 NM_NAME = 'org.freedesktop.NetworkManager'
493 NM_PATH = '/org/freedesktop/NetworkManager'
495 NMCA_IFACE = NM_NAME + '.Connection.Active'
497 NM_STATE_CONNECTED = 3 #obsolete
498 NM_STATE_CONNECTED_LOCAL = 50
499 NM_STATE_CONNECTED_SITE = 60
500 NM_STATE_CONNECTED_GLOBAL = 70
501 NM_CONNSTATES = set([NM_STATE_CONNECTED,
502 NM_STATE_CONNECTED_LOCAL,
503 NM_STATE_CONNECTED_SITE,
504 NM_STATE_CONNECTED_GLOBAL])
506 class NetworkManagerMonitor (object):
508 Watch NetworkManager signals for changes in network state.
511 ## Strategy. There are two kinds of interesting state transitions for us.
512 ## The first one is the global are-we-connected state, which we'll use to
513 ## toggle network upness on a global level. The second is which connection
514 ## has the default route, which we'll use to tweak which peer in the peer
515 ## group is active. The former is most easily tracked using the signal
516 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
517 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
518 ## look for when a new connection gains the default route.
522 nm = bus.get_object(NM_NAME, NM_PATH)
523 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
524 if state in NM_CONNSTATES:
525 netupdown(True, ['nm', 'initially-connected'])
527 netupdown(False, ['nm', 'initially-disconnected'])
528 except D.DBusException, e:
529 if T._debug: print '# exception attaching to network-manager: %s' % e
530 bus.add_signal_receiver(me._nm_state, 'StateChanged',
531 NM_IFACE, NM_NAME, NM_PATH)
532 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
533 NMCA_IFACE, NM_NAME, None)
535 def _nm_state(me, state):
536 if state in NM_CONNSTATES:
537 delay_netupdown(True, ['nm', 'connected'])
539 delay_netupdown(False, ['nm', 'disconnected'])
541 def _nm_connchange(me, props):
542 if props.get('Default', False) or props.get('Default6', False):
543 delay_netupdown(True, ['nm', 'default-connection-change'])
545 ##--------------------------------------------------------------------------
548 CM_NAME = 'net.connman'
550 CM_IFACE = 'net.connman.Manager'
552 class ConnManMonitor (object):
554 Watch ConnMan signls for changes in network state.
557 ## Strategy. Everything seems to be usefully encoded in the `State'
558 ## property. If it's `offline', `idle' or `ready' then we don't expect a
559 ## network connection. During handover from one network to another, the
560 ## property passes through `ready' to `online'.
564 cm = bus.get_object(CM_NAME, CM_PATH)
565 props = cm.GetProperties(dbus_interface = CM_IFACE)
566 state = props['State']
567 netupdown(state == 'online', ['connman', 'initially-%s' % state])
568 except D.DBusException, e:
569 if T._debug: print '# exception attaching to connman: %s' % e
570 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
571 CM_IFACE, CM_NAME, CM_PATH)
573 def _cm_state(me, prop, value):
574 if prop != 'State': return
575 delay_netupdown(value == 'online', ['connman', value])
577 ###--------------------------------------------------------------------------
580 ICD_NAME = 'com.nokia.icd'
581 ICD_PATH = '/com/nokia/icd'
584 class MaemoICdMonitor (object):
586 Watch ICd signals for changes in network state.
589 ## Strategy. ICd only handles one connection at a time in steady state,
590 ## though when switching between connections, it tries to bring the new one
591 ## up before shutting down the old one. This makes life a bit easier than
592 ## it is with NetworkManager. On the other hand, the notifications are
593 ## relative to particular connections only, and the indicator that the old
594 ## connection is down (`IDLE') comes /after/ the new one comes up
595 ## (`CONNECTED'), so we have to remember which one is active.
599 icd = bus.get_object(ICD_NAME, ICD_PATH)
601 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
603 netupdown(True, ['icd', 'initially-connected', iap])
604 except D.DBusException:
606 netupdown(False, ['icd', 'initially-disconnected'])
607 except D.DBusException, e:
608 if T._debug: print '# exception attaching to icd: %s' % e
610 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
613 def _icd_state(me, iap, ty, state, hunoz):
614 if state == 'CONNECTED':
616 delay_netupdown(True, ['icd', 'connected', iap])
617 elif state == 'IDLE' and iap == me._iap:
619 delay_netupdown(False, ['icd', 'idle'])
621 ###--------------------------------------------------------------------------
622 ### D-Bus connection tracking.
624 class DBusMonitor (object):
626 Maintains a connection to the system D-Bus, and watches for signals.
628 If the connection is initially down, or drops for some reason, we retry
629 periodically (every five seconds at the moment). If the connection
630 resurfaces, we reattach the monitors.
635 Initialise the object and try to establish a connection to the bus.
638 me._loop = D.mainloop.glib.DBusGMainLoop()
639 me._state = 'startup'
644 Add a monitor object to watch for signals.
646 MON.attach(BUS) is called, with BUS being the connection to the system
647 bus. MON should query its service's current status and watch for
651 if me._bus is not None:
654 def _reconnect(me, hunoz = None):
656 Start connecting to the bus.
658 If we fail the first time, retry periodically.
660 if me._state == 'startup':
661 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
662 elif me._state == 'connected':
663 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
665 T.aside(SM.notify, 'conntrack', 'dbus-connection',
666 'state=%s' % me._state)
667 me._state == 'reconnecting'
669 if me._try_connect():
670 G.timeout_add_seconds(5, me._try_connect)
672 def _try_connect(me):
674 Actually make a connection attempt.
676 If we succeed, attach the monitors.
679 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
680 if addr == 'SESSION':
681 bus = D.SessionBus(mainloop = me._loop, private = True)
682 elif addr is not None:
683 bus = D.bus.BusConnection(addr, mainloop = me._loop)
685 bus = D.SystemBus(mainloop = me._loop, private = True)
688 except D.DBusException, e:
691 me._state = 'connected'
692 bus.call_on_disconnection(me._reconnect)
693 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
696 ###--------------------------------------------------------------------------
699 class GIOWatcher (object):
701 Monitor I/O events using glib.
703 def __init__(me, conn, mc = G.main_context_default()):
707 def connected(me, sock):
708 me._watch = G.io_add_watch(sock, G.IO_IN,
709 lambda *hunoz: me._conn.receive())
710 def disconnected(me):
711 G.source_remove(me._watch)
714 me._mc.iteration(True)
716 SM.iowatch = GIOWatcher(SM)
720 Service initialization.
722 Add the D-Bus monitor here, because we might send commands off immediately,
723 and we want to make sure the server connection is up.
726 T.Coroutine(kickpeers, name = 'kickpeers').switch()
728 DBM.addmon(NetworkManagerMonitor())
729 DBM.addmon(ConnManMonitor())
730 DBM.addmon(MaemoICdMonitor())
731 G.timeout_add_seconds(30, lambda: (_delay is not None or
732 netupdown(True, ['interval-timer']) or
737 Parse the command-line options.
739 Automatically changes directory to the requested configdir, and turns on
740 debugging. Returns the options object.
742 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
743 version = '%%prog %s' % VERSION)
745 op.add_option('-a', '--admin-socket',
746 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
747 help = 'Select socket to connect to [default %default]')
748 op.add_option('-d', '--directory',
749 metavar = 'DIR', dest = 'dir', default = T.configdir,
750 help = 'Select current diretory [default %default]')
751 op.add_option('-c', '--config',
752 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
753 help = 'Select configuration [default %default]')
754 op.add_option('--daemon', dest = 'daemon',
755 default = False, action = 'store_true',
756 help = 'Become a daemon after successful initialization')
757 op.add_option('--debug', dest = 'debug',
758 default = False, action = 'store_true',
759 help = 'Emit debugging trace information')
760 op.add_option('--startup', dest = 'startup',
761 default = False, action = 'store_true',
762 help = 'Being called as part of the server startup')
764 opts, args = op.parse_args()
765 if args: op.error('no arguments permitted')
767 T._debug = opts.debug
770 ## Service table, for running manually.
771 def cmd_updown(upness):
772 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
773 service_info = [('conntrack', VERSION, {
774 'up': (0, None, '', cmd_updown(True)),
775 'down': (0, None, '', cmd_updown(False)),
776 'show-config': (0, 0, '', cmd_showconfig),
777 'show-groups': (0, 0, '', cmd_showgroups),
778 'show-group': (1, 1, 'GROUP', cmd_showgroup)
781 if __name__ == '__main__':
782 opts = parse_options()
783 CF = Config(opts.conf)
784 T.runservices(opts.tripesock, service_info,
785 init = init, daemon = opts.daemon)
787 ###----- That's all, folks --------------------------------------------------