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 class Inet6Address (BaseAddress):
150 def _setaddr(me, addrstr):
151 pc = addrstr.find('%')
153 me.addr = me._addrstr_to_int(addrstr)
156 me.addr = me._addrstr_to_int(addrstr[:pc])
157 ais = S.getaddrinfo(addrstr, 0, S.AF_INET6, S.SOCK_DGRAM, 0,
158 S.AI_NUMERICHOST | S.AI_NUMERICSERV)
159 me.scope = ais[0][4][3]
161 addrstr = me._int_to_addrstr(me.addr)
165 name, _ = S.getnameinfo((addrstr, 0, 0, me.scope),
166 S.NI_NUMERICHOST | S.NI_NUMERICSERV)
168 def _sockaddr(me, port = 0):
169 return (me._addrstr(), port, 0, me.scope)
171 def from_sockaddr(cls, sa):
172 addr, port, _, scope = (lambda a, p, f = 0, s = 0: (a, p, f, s))(*sa)
176 def _withinp(me, net):
177 return net.scope == 0 or me.scope == net.scope
179 return me.scope == other.scope
181 def parse_address(addrstr, maskstr = None):
182 if addrstr.find(':') >= 0: return Inet6Address(addrstr, maskstr)
183 else: return InetAddress(addrstr, maskstr)
185 def parse_net(netstr):
186 try: sl = netstr.index('/')
187 except ValueError: raise ValueError('missing mask')
188 return parse_address(netstr[:sl], netstr[sl + 1:])
190 def straddr(a): return a is None and '#<none>' or str(a)
192 ###--------------------------------------------------------------------------
193 ### Parse the configuration file.
195 ## Hmm. Should I try to integrate this with the peers database? It's not a
196 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
197 ## this service are largely going to be satellite notes, I don't think
198 ## scalability's going to be a problem.
200 TESTADDRS = [InetAddress('1.2.3.4'), Inet6Address('2001::1')]
203 ('COMMENT', RX.compile(r'^\s*($|[;#])')),
204 ('GRPHDR', RX.compile(r'^\s*\[(.*)\]\s*$')),
205 ('ASSGN', RX.compile(r'\s*([\w.-]+)\s*[:=]\s*(|\S|\S.*\S)\s*$'))]
207 class ConfigError (Exception):
208 def __init__(me, file, lno, msg):
213 return '%s:%d: %s' % (me.file, me.lno, me.msg)
215 class Config (object):
217 Represents a configuration file.
219 The most interesting thing is probably the `groups' slot, which stores a
220 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
221 list of (TAG, PEER, NETS) triples. The implication is that there should be
222 precisely one peer from the set, and that it should be named TAG, where
223 (TAG, PEER, NETS) is the first triple such that the host's primary IP
224 address (if PEER is None -- or the IP address it would use for
225 communicating with PEER) is within one of the networks defined by NETS.
228 def __init__(me, file):
230 Construct a new Config object, reading the given FILE.
233 me._fwatch = M.FWatch(file)
238 See whether the configuration file has been updated.
240 if me._fwatch.update():
245 Internal function to update the configuration from the underlying file.
248 if T._debug: print '# reread config'
256 ## Open the file and start reading.
257 with open(me._file) as f:
261 for tag, rx in CONFSYNTAX:
265 raise ConfigError(me._file, lno, 'failed to parse line: %r' % line)
268 ## A comment. Ignore it and hope it goes away.
272 elif tag == 'GRPHDR':
273 ## A group header. Flush the old group and start a new one.
276 if grpname is not None: groups[grpname] = grplist
277 if newname in groups:
278 raise ConfigError(me._file, lno,
279 "duplicate group name `%s'" % newname)
284 ## An assignment. Deal with it.
285 name, value = m.group(1), m.group(2)
288 ## We're outside of any group, so this is a global configuration
291 if name == 'test-addr':
292 for astr in value.split():
294 a = parse_address(astr)
296 raise ConfigError(me._file, lno,
297 "invalid IP address `%s': %s" %
299 if a.AF in testaddrs:
300 raise ConfigError(me._file, lno,
301 'duplicate %s test-address' % a.AFNAME)
304 raise ConfigError(me._file, lno,
305 "unknown global option `%s'" % name)
308 ## Parse a pattern and add it to the group.
312 ## Check for an explicit target address.
313 if i >= len(spec) or spec[i].find('/') >= 0:
318 peer = parse_address(spec[i])
320 raise ConfigError(me._file, lno,
321 "invalid IP address `%s': %s" %
326 ## Parse the list of local networks.
330 net = parse_net(spec[i])
332 raise ConfigError(me._file, lno,
333 "invalid IP network `%s': %s" %
339 raise ConfigError(me._file, lno, 'no networks defined')
341 ## Make sure that the addresses are consistent.
346 raise ConfigError(me._file, lno,
347 "net %s doesn't match" % net)
349 ## Add this entry to the list.
350 grplist.append((name, peer, nets))
352 ## Fill in the default test addresses if necessary.
353 for a in TESTADDRS: testaddrs.setdefault(a.AF, a)
356 if grpname is not None: groups[grpname] = grplist
357 me.testaddrs = testaddrs
360 ### This will be a configuration file.
363 def cmd_showconfig():
364 T.svcinfo('test-addr=%s' %
366 for a in sorted(CF.testaddrs.itervalues(),
367 key = lambda a: a.AFNAME)))
368 def cmd_showgroups():
369 for g in sorted(CF.groups.iterkeys()):
371 def cmd_showgroup(g):
372 try: pats = CF.groups[g]
373 except KeyError: raise T.TripeJobError('unknown-group', g)
374 for t, p, nn in pats:
376 'target', p and str(p) or '(default)',
377 'net', ' '.join(map(str, nn)))
379 ###--------------------------------------------------------------------------
380 ### Responding to a network up/down event.
384 Return the local IP address used for talking to PEER.
386 sk = S.socket(peer.AF, S.SOCK_DGRAM)
389 sk.connect(peer.sockaddr(1))
390 addr = sk.getsockname()
391 return type(peer).from_sockaddr(addr)[0]
402 if _delay is not None:
403 if T._debug: print '# cancel delayed kick'
404 G.source_remove(_delay)
407 def netupdown(upness, reason):
409 Add or kill peers according to whether the network is up or down.
411 UPNESS is true if the network is up, or false if it's down.
414 _kick.put((upness, reason))
416 def delay_netupdown(upness, reason):
421 if T._debug: print '# delayed %s: %s' % (upness, reason)
423 netupdown(upness, reason)
425 if T._debug: print '# delaying %s: %s' % (upness, reason)
426 _delay = G.timeout_add(2000, _func)
430 upness, reason = _kick.get()
431 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
435 ## Make sure the configuration file is up-to-date. Don't worry if we
436 ## can't do anything useful.
439 except Exception, exc:
440 SM.warn('conntrack', 'config-file-error',
441 exc.__class__.__name__, str(exc))
443 ## Find the current list of peers.
446 ## Work out the primary IP addresses.
449 for af, remote in CF.testaddrs.iteritems():
450 local = localaddr(remote)
451 if local is not None: locals[af] = local
452 if not locals: upness = False
453 if not T._debug: pass
454 elif not locals: print '# offline'
456 for local in locals.itervalues():
457 print '# local %s address = %s' % (local.AFNAME, local)
459 ## Now decide what to do.
461 for g, pp in CF.groups.iteritems():
462 if T._debug: print '# check group %s' % g
464 ## Find out which peer in the group ought to be active.
470 if p is None or not upness: ip = locals.get(af)
471 else: ip = localaddr(p)
473 info = 'peer = %s; target = %s; nets = %s; local = %s' % (
474 t, p or '(default)', ', '.join(map(str, nn)), straddr(ip))
475 if upness and not matchp and \
476 ip is not None and any(ip.withinp(n) for n in nn):
477 if T._debug: print '# %s: SELECTED' % info
479 select.append('%s=%s' % (g, t))
480 if t == 'down' or t.startswith('down/'): want = None
485 if T._debug: print '# %s: skipped' % info
487 ## Shut down the wrong ones.
489 if T._debug: print '# peer-map = %r' % statemap
491 what = statemap.get(p, 'leave')
494 if T._debug: print '# peer %s: already up' % p
499 except T.TripeError, exc:
500 if exc.args[0] == 'unknown-peer':
501 ## Inherently racy; don't worry about this.
505 if T._debug: print '# peer %s: bring down' % p
508 ## Start the right one if necessary.
509 if want is not None and not found:
512 list(SM.svcsubmit('connect', 'active', want))
513 except T.TripeError, exc:
514 SM.warn('conntrack', 'connect-failed', want, *exc.args)
515 if T._debug: print '# peer %s: bring up' % want
518 ## Commit the changes.
520 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
521 for c in changes: c()
523 ###--------------------------------------------------------------------------
524 ### NetworkManager monitor.
526 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
528 NM_NAME = 'org.freedesktop.NetworkManager'
529 NM_PATH = '/org/freedesktop/NetworkManager'
531 NMCA_IFACE = NM_NAME + '.Connection.Active'
533 NM_STATE_CONNECTED = 3 #obsolete
534 NM_STATE_CONNECTED_LOCAL = 50
535 NM_STATE_CONNECTED_SITE = 60
536 NM_STATE_CONNECTED_GLOBAL = 70
537 NM_CONNSTATES = set([NM_STATE_CONNECTED,
538 NM_STATE_CONNECTED_LOCAL,
539 NM_STATE_CONNECTED_SITE,
540 NM_STATE_CONNECTED_GLOBAL])
542 class NetworkManagerMonitor (object):
544 Watch NetworkManager signals for changes in network state.
547 ## Strategy. There are two kinds of interesting state transitions for us.
548 ## The first one is the global are-we-connected state, which we'll use to
549 ## toggle network upness on a global level. The second is which connection
550 ## has the default route, which we'll use to tweak which peer in the peer
551 ## group is active. The former is most easily tracked using the signal
552 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
553 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
554 ## look for when a new connection gains the default route.
558 nm = bus.get_object(NM_NAME, NM_PATH)
559 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
560 if state in NM_CONNSTATES:
561 netupdown(True, ['nm', 'initially-connected'])
563 netupdown(False, ['nm', 'initially-disconnected'])
564 except D.DBusException, e:
565 if T._debug: print '# exception attaching to network-manager: %s' % e
566 bus.add_signal_receiver(me._nm_state, 'StateChanged',
567 NM_IFACE, NM_NAME, NM_PATH)
568 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
569 NMCA_IFACE, NM_NAME, None)
571 def _nm_state(me, state):
572 if state in NM_CONNSTATES:
573 delay_netupdown(True, ['nm', 'connected'])
575 delay_netupdown(False, ['nm', 'disconnected'])
577 def _nm_connchange(me, props):
578 if props.get('Default', False) or props.get('Default6', False):
579 delay_netupdown(True, ['nm', 'default-connection-change'])
581 ##--------------------------------------------------------------------------
584 CM_NAME = 'net.connman'
586 CM_IFACE = 'net.connman.Manager'
588 class ConnManMonitor (object):
590 Watch ConnMan signls for changes in network state.
593 ## Strategy. Everything seems to be usefully encoded in the `State'
594 ## property. If it's `offline', `idle' or `ready' then we don't expect a
595 ## network connection. During handover from one network to another, the
596 ## property passes through `ready' to `online'.
600 cm = bus.get_object(CM_NAME, CM_PATH)
601 props = cm.GetProperties(dbus_interface = CM_IFACE)
602 state = props['State']
603 netupdown(state == 'online', ['connman', 'initially-%s' % state])
604 except D.DBusException, e:
605 if T._debug: print '# exception attaching to connman: %s' % e
606 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
607 CM_IFACE, CM_NAME, CM_PATH)
609 def _cm_state(me, prop, value):
610 if prop != 'State': return
611 delay_netupdown(value == 'online', ['connman', value])
613 ###--------------------------------------------------------------------------
616 ICD_NAME = 'com.nokia.icd'
617 ICD_PATH = '/com/nokia/icd'
620 class MaemoICdMonitor (object):
622 Watch ICd signals for changes in network state.
625 ## Strategy. ICd only handles one connection at a time in steady state,
626 ## though when switching between connections, it tries to bring the new one
627 ## up before shutting down the old one. This makes life a bit easier than
628 ## it is with NetworkManager. On the other hand, the notifications are
629 ## relative to particular connections only, and the indicator that the old
630 ## connection is down (`IDLE') comes /after/ the new one comes up
631 ## (`CONNECTED'), so we have to remember which one is active.
635 icd = bus.get_object(ICD_NAME, ICD_PATH)
637 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
639 netupdown(True, ['icd', 'initially-connected', iap])
640 except D.DBusException:
642 netupdown(False, ['icd', 'initially-disconnected'])
643 except D.DBusException, e:
644 if T._debug: print '# exception attaching to icd: %s' % e
646 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
649 def _icd_state(me, iap, ty, state, hunoz):
650 if state == 'CONNECTED':
652 delay_netupdown(True, ['icd', 'connected', iap])
653 elif state == 'IDLE' and iap == me._iap:
655 delay_netupdown(False, ['icd', 'idle'])
657 ###--------------------------------------------------------------------------
658 ### D-Bus connection tracking.
660 class DBusMonitor (object):
662 Maintains a connection to the system D-Bus, and watches for signals.
664 If the connection is initially down, or drops for some reason, we retry
665 periodically (every five seconds at the moment). If the connection
666 resurfaces, we reattach the monitors.
671 Initialise the object and try to establish a connection to the bus.
674 me._loop = D.mainloop.glib.DBusGMainLoop()
675 me._state = 'startup'
680 Add a monitor object to watch for signals.
682 MON.attach(BUS) is called, with BUS being the connection to the system
683 bus. MON should query its service's current status and watch for
687 if me._bus is not None:
690 def _reconnect(me, hunoz = None):
692 Start connecting to the bus.
694 If we fail the first time, retry periodically.
696 if me._state == 'startup':
697 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
698 elif me._state == 'connected':
699 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
701 T.aside(SM.notify, 'conntrack', 'dbus-connection',
702 'state=%s' % me._state)
703 me._state == 'reconnecting'
705 if me._try_connect():
706 G.timeout_add_seconds(5, me._try_connect)
708 def _try_connect(me):
710 Actually make a connection attempt.
712 If we succeed, attach the monitors.
715 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
716 if addr == 'SESSION':
717 bus = D.SessionBus(mainloop = me._loop, private = True)
718 elif addr is not None:
719 bus = D.bus.BusConnection(addr, mainloop = me._loop)
721 bus = D.SystemBus(mainloop = me._loop, private = True)
724 except D.DBusException, e:
727 me._state = 'connected'
728 bus.call_on_disconnection(me._reconnect)
729 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
732 ###--------------------------------------------------------------------------
735 class GIOWatcher (object):
737 Monitor I/O events using glib.
739 def __init__(me, conn, mc = G.main_context_default()):
743 def connected(me, sock):
744 me._watch = G.io_add_watch(sock, G.IO_IN,
745 lambda *hunoz: me._conn.receive())
746 def disconnected(me):
747 G.source_remove(me._watch)
750 me._mc.iteration(True)
752 SM.iowatch = GIOWatcher(SM)
756 Service initialization.
758 Add the D-Bus monitor here, because we might send commands off immediately,
759 and we want to make sure the server connection is up.
762 T.Coroutine(kickpeers, name = 'kickpeers').switch()
764 DBM.addmon(NetworkManagerMonitor())
765 DBM.addmon(ConnManMonitor())
766 DBM.addmon(MaemoICdMonitor())
767 G.timeout_add_seconds(30, lambda: (_delay is not None or
768 netupdown(True, ['interval-timer']) or
773 Parse the command-line options.
775 Automatically changes directory to the requested configdir, and turns on
776 debugging. Returns the options object.
778 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
779 version = '%%prog %s' % VERSION)
781 op.add_option('-a', '--admin-socket',
782 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
783 help = 'Select socket to connect to [default %default]')
784 op.add_option('-d', '--directory',
785 metavar = 'DIR', dest = 'dir', default = T.configdir,
786 help = 'Select current diretory [default %default]')
787 op.add_option('-c', '--config',
788 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
789 help = 'Select configuration [default %default]')
790 op.add_option('--daemon', dest = 'daemon',
791 default = False, action = 'store_true',
792 help = 'Become a daemon after successful initialization')
793 op.add_option('--debug', dest = 'debug',
794 default = False, action = 'store_true',
795 help = 'Emit debugging trace information')
796 op.add_option('--startup', dest = 'startup',
797 default = False, action = 'store_true',
798 help = 'Being called as part of the server startup')
800 opts, args = op.parse_args()
801 if args: op.error('no arguments permitted')
803 T._debug = opts.debug
806 ## Service table, for running manually.
807 def cmd_updown(upness):
808 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
809 service_info = [('conntrack', VERSION, {
810 'up': (0, None, '', cmd_updown(True)),
811 'down': (0, None, '', cmd_updown(False)),
812 'show-config': (0, 0, '', cmd_showconfig),
813 'show-groups': (0, 0, '', cmd_showgroups),
814 'show-group': (1, 1, 'GROUP', cmd_showgroup)
817 if __name__ == '__main__':
818 opts = parse_options()
819 CF = Config(opts.conf)
820 T.runservices(opts.tripesock, service_info,
821 init = init, daemon = opts.daemon)
823 ###----- That's all, folks --------------------------------------------------