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
47 ##__import__('rmcr').__debug = True
49 ###--------------------------------------------------------------------------
52 class struct (object):
53 """A simple container object."""
54 def __init__(me, **kw):
55 me.__dict__.update(kw)
57 ###--------------------------------------------------------------------------
58 ### Address manipulation.
60 class InetAddress (object):
63 def __init__(me, addrstr, maskstr = None):
64 me.addr = me._addrstr_to_int(addrstr)
67 elif maskstr.isdigit():
68 me.mask = (1 << 32) - (1 << 32 - int(maskstr))
70 me.mask = me._addrstr_to_int(maskstr)
72 raise ValueError('network contains bits set beyond mask')
73 def _addrstr_to_int(me, addrstr):
74 return unpack('>L', S.inet_aton(addrstr))[0]
75 def _int_to_addrstr(me, n):
76 return S.inet_ntoa(pack('>L', n))
77 def sockaddr(me, port = 0):
78 if me.mask != -1: raise ValueError('not a simple address')
79 return me._int_to_addrstr(me.addr), port
81 addrstr = me._int_to_addrstr(me.addr)
85 inv = me.mask ^ ((1 << 32) - 1)
86 if (inv&(inv + 1)) == 0:
87 return '%s/%d' % (addrstr, 32 - inv.bit_length())
89 return '%s/%s' % (addrstr, me._int_to_addrstr(me.mask))
91 if (me.mask&net.mask) != net.mask: return False
92 if (me.addr ^ net.addr)&net.mask: return False
95 if me.mask != other.mask: return False
96 if me.addr != other.addr: return False
99 def from_sockaddr(cls, sa):
100 addr, port = (lambda a, p: (a, p))(*sa)
101 return cls(addr), port
103 def parse_address(addrstr, maskstr = None):
104 return InetAddress(addrstr, maskstr)
106 def parse_net(netstr):
107 try: sl = netstr.index('/')
108 except ValueError: raise ValueError('missing mask')
109 return parse_address(netstr[:sl], netstr[sl + 1:])
111 def straddr(a): return a is None and '#<none>' or str(a)
113 ###--------------------------------------------------------------------------
114 ### Parse the configuration file.
116 ## Hmm. Should I try to integrate this with the peers database? It's not a
117 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
118 ## this service are largely going to be satellite notes, I don't think
119 ## scalability's going to be a problem.
121 TESTADDRS = [InetAddress('1.2.3.4')]
124 ('COMMENT', RX.compile(r'^\s*($|[;#])')),
125 ('GRPHDR', RX.compile(r'^\s*\[(.*)\]\s*$')),
126 ('ASSGN', RX.compile(r'\s*([\w.-]+)\s*[:=]\s*(|\S|\S.*\S)\s*$'))]
128 class ConfigError (Exception):
129 def __init__(me, file, lno, msg):
134 return '%s:%d: %s' % (me.file, me.lno, me.msg)
136 class Config (object):
138 Represents a configuration file.
140 The most interesting thing is probably the `groups' slot, which stores a
141 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
142 list of (TAG, PEER, NETS) triples. The implication is that there should be
143 precisely one peer from the set, and that it should be named TAG, where
144 (TAG, PEER, NETS) is the first triple such that the host's primary IP
145 address (if PEER is None -- or the IP address it would use for
146 communicating with PEER) is within one of the networks defined by NETS.
149 def __init__(me, file):
151 Construct a new Config object, reading the given FILE.
154 me._fwatch = M.FWatch(file)
159 See whether the configuration file has been updated.
161 if me._fwatch.update():
166 Internal function to update the configuration from the underlying file.
169 if T._debug: print '# reread config'
177 ## Open the file and start reading.
178 with open(me._file) as f:
182 for tag, rx in CONFSYNTAX:
186 raise ConfigError(me._file, lno, 'failed to parse line: %r' % line)
189 ## A comment. Ignore it and hope it goes away.
193 elif tag == 'GRPHDR':
194 ## A group header. Flush the old group and start a new one.
197 if grpname is not None: groups[grpname] = grplist
198 if newname in groups:
199 raise ConfigError(me._file, lno,
200 "duplicate group name `%s'" % newname)
205 ## An assignment. Deal with it.
206 name, value = m.group(1), m.group(2)
209 ## We're outside of any group, so this is a global configuration
212 if name == 'test-addr':
213 for astr in value.split():
215 a = parse_address(astr)
217 raise ConfigError(me._file, lno,
218 "invalid IP address `%s': %s" %
220 if a.AF in testaddrs:
221 raise ConfigError(me._file, lno,
222 'duplicate %s test-address' % a.AFNAME)
225 raise ConfigError(me._file, lno,
226 "unknown global option `%s'" % name)
229 ## Parse a pattern and add it to the group.
233 ## Check for an explicit target address.
234 if i >= len(spec) or spec[i].find('/') >= 0:
239 peer = parse_address(spec[i])
241 raise ConfigError(me._file, lno,
242 "invalid IP address `%s': %s" %
247 ## Parse the list of local networks.
251 net = parse_net(spec[i])
253 raise ConfigError(me._file, lno,
254 "invalid IP network `%s': %s" %
260 raise ConfigError(me._file, lno, 'no networks defined')
262 ## Make sure that the addresses are consistent.
267 raise ConfigError(me._file, lno,
268 "net %s doesn't match" % net)
270 ## Add this entry to the list.
271 grplist.append((name, peer, nets))
273 ## Fill in the default test addresses if necessary.
274 for a in TESTADDRS: testaddrs.setdefault(a.AF, a)
277 if grpname is not None: groups[grpname] = grplist
278 me.testaddrs = testaddrs
281 ### This will be a configuration file.
284 def cmd_showconfig():
285 T.svcinfo('test-addr=%s' %
287 for a in sorted(CF.testaddrs.itervalues(),
288 key = lambda a: a.AFNAME)))
289 def cmd_showgroups():
290 for g in sorted(CF.groups.iterkeys()):
292 def cmd_showgroup(g):
293 try: pats = CF.groups[g]
294 except KeyError: raise T.TripeJobError('unknown-group', g)
295 for t, p, nn in pats:
297 'target', p and str(p) or '(default)',
298 'net', ' '.join(map(str, nn)))
300 ###--------------------------------------------------------------------------
301 ### Responding to a network up/down event.
305 Return the local IP address used for talking to PEER.
307 sk = S.socket(peer.AF, S.SOCK_DGRAM)
310 sk.connect(peer.sockaddr(1))
311 addr = sk.getsockname()
312 return type(peer).from_sockaddr(addr)[0]
323 if _delay is not None:
324 if T._debug: print '# cancel delayed kick'
325 G.source_remove(_delay)
328 def netupdown(upness, reason):
330 Add or kill peers according to whether the network is up or down.
332 UPNESS is true if the network is up, or false if it's down.
335 _kick.put((upness, reason))
337 def delay_netupdown(upness, reason):
342 if T._debug: print '# delayed %s: %s' % (upness, reason)
344 netupdown(upness, reason)
346 if T._debug: print '# delaying %s: %s' % (upness, reason)
347 _delay = G.timeout_add(2000, _func)
351 upness, reason = _kick.get()
352 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
356 ## Make sure the configuration file is up-to-date. Don't worry if we
357 ## can't do anything useful.
360 except Exception, exc:
361 SM.warn('conntrack', 'config-file-error',
362 exc.__class__.__name__, str(exc))
364 ## Find the current list of peers.
367 ## Work out the primary IP addresses.
370 for af, remote in CF.testaddrs.iteritems():
371 local = localaddr(remote)
372 if local is not None: locals[af] = local
373 if not locals: upness = False
374 if not T._debug: pass
375 elif not locals: print '# offline'
377 for local in locals.itervalues():
378 print '# local %s address = %s' % (local.AFNAME, local)
380 ## Now decide what to do.
382 for g, pp in CF.groups.iteritems():
383 if T._debug: print '# check group %s' % g
385 ## Find out which peer in the group ought to be active.
391 if p is None or not upness: ip = locals.get(af)
392 else: ip = localaddr(p)
394 info = 'peer = %s; target = %s; nets = %s; local = %s' % (
395 t, p or '(default)', ', '.join(map(str, nn)), straddr(ip))
396 if upness and not matchp and \
397 ip is not None and any(ip.withinp(n) for n in nn):
398 if T._debug: print '# %s: SELECTED' % info
400 select.append('%s=%s' % (g, t))
401 if t == 'down' or t.startswith('down/'): want = None
406 if T._debug: print '# %s: skipped' % info
408 ## Shut down the wrong ones.
410 if T._debug: print '# peer-map = %r' % statemap
412 what = statemap.get(p, 'leave')
415 if T._debug: print '# peer %s: already up' % p
420 except T.TripeError, exc:
421 if exc.args[0] == 'unknown-peer':
422 ## Inherently racy; don't worry about this.
426 if T._debug: print '# peer %s: bring down' % p
429 ## Start the right one if necessary.
430 if want is not None and not found:
433 list(SM.svcsubmit('connect', 'active', want))
434 except T.TripeError, exc:
435 SM.warn('conntrack', 'connect-failed', want, *exc.args)
436 if T._debug: print '# peer %s: bring up' % want
439 ## Commit the changes.
441 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
442 for c in changes: c()
444 ###--------------------------------------------------------------------------
445 ### NetworkManager monitor.
447 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
449 NM_NAME = 'org.freedesktop.NetworkManager'
450 NM_PATH = '/org/freedesktop/NetworkManager'
452 NMCA_IFACE = NM_NAME + '.Connection.Active'
454 NM_STATE_CONNECTED = 3 #obsolete
455 NM_STATE_CONNECTED_LOCAL = 50
456 NM_STATE_CONNECTED_SITE = 60
457 NM_STATE_CONNECTED_GLOBAL = 70
458 NM_CONNSTATES = set([NM_STATE_CONNECTED,
459 NM_STATE_CONNECTED_LOCAL,
460 NM_STATE_CONNECTED_SITE,
461 NM_STATE_CONNECTED_GLOBAL])
463 class NetworkManagerMonitor (object):
465 Watch NetworkManager signals for changes in network state.
468 ## Strategy. There are two kinds of interesting state transitions for us.
469 ## The first one is the global are-we-connected state, which we'll use to
470 ## toggle network upness on a global level. The second is which connection
471 ## has the default route, which we'll use to tweak which peer in the peer
472 ## group is active. The former is most easily tracked using the signal
473 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
474 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
475 ## look for when a new connection gains the default route.
479 nm = bus.get_object(NM_NAME, NM_PATH)
480 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
481 if state in NM_CONNSTATES:
482 netupdown(True, ['nm', 'initially-connected'])
484 netupdown(False, ['nm', 'initially-disconnected'])
485 except D.DBusException, e:
486 if T._debug: print '# exception attaching to network-manager: %s' % e
487 bus.add_signal_receiver(me._nm_state, 'StateChanged',
488 NM_IFACE, NM_NAME, NM_PATH)
489 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
490 NMCA_IFACE, NM_NAME, None)
492 def _nm_state(me, state):
493 if state in NM_CONNSTATES:
494 delay_netupdown(True, ['nm', 'connected'])
496 delay_netupdown(False, ['nm', 'disconnected'])
498 def _nm_connchange(me, props):
499 if props.get('Default', False) or props.get('Default6', False):
500 delay_netupdown(True, ['nm', 'default-connection-change'])
502 ##--------------------------------------------------------------------------
505 CM_NAME = 'net.connman'
507 CM_IFACE = 'net.connman.Manager'
509 class ConnManMonitor (object):
511 Watch ConnMan signls for changes in network state.
514 ## Strategy. Everything seems to be usefully encoded in the `State'
515 ## property. If it's `offline', `idle' or `ready' then we don't expect a
516 ## network connection. During handover from one network to another, the
517 ## property passes through `ready' to `online'.
521 cm = bus.get_object(CM_NAME, CM_PATH)
522 props = cm.GetProperties(dbus_interface = CM_IFACE)
523 state = props['State']
524 netupdown(state == 'online', ['connman', 'initially-%s' % state])
525 except D.DBusException, e:
526 if T._debug: print '# exception attaching to connman: %s' % e
527 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
528 CM_IFACE, CM_NAME, CM_PATH)
530 def _cm_state(me, prop, value):
531 if prop != 'State': return
532 delay_netupdown(value == 'online', ['connman', value])
534 ###--------------------------------------------------------------------------
537 ICD_NAME = 'com.nokia.icd'
538 ICD_PATH = '/com/nokia/icd'
541 class MaemoICdMonitor (object):
543 Watch ICd signals for changes in network state.
546 ## Strategy. ICd only handles one connection at a time in steady state,
547 ## though when switching between connections, it tries to bring the new one
548 ## up before shutting down the old one. This makes life a bit easier than
549 ## it is with NetworkManager. On the other hand, the notifications are
550 ## relative to particular connections only, and the indicator that the old
551 ## connection is down (`IDLE') comes /after/ the new one comes up
552 ## (`CONNECTED'), so we have to remember which one is active.
556 icd = bus.get_object(ICD_NAME, ICD_PATH)
558 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
560 netupdown(True, ['icd', 'initially-connected', iap])
561 except D.DBusException:
563 netupdown(False, ['icd', 'initially-disconnected'])
564 except D.DBusException, e:
565 if T._debug: print '# exception attaching to icd: %s' % e
567 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
570 def _icd_state(me, iap, ty, state, hunoz):
571 if state == 'CONNECTED':
573 delay_netupdown(True, ['icd', 'connected', iap])
574 elif state == 'IDLE' and iap == me._iap:
576 delay_netupdown(False, ['icd', 'idle'])
578 ###--------------------------------------------------------------------------
579 ### D-Bus connection tracking.
581 class DBusMonitor (object):
583 Maintains a connection to the system D-Bus, and watches for signals.
585 If the connection is initially down, or drops for some reason, we retry
586 periodically (every five seconds at the moment). If the connection
587 resurfaces, we reattach the monitors.
592 Initialise the object and try to establish a connection to the bus.
595 me._loop = D.mainloop.glib.DBusGMainLoop()
596 me._state = 'startup'
601 Add a monitor object to watch for signals.
603 MON.attach(BUS) is called, with BUS being the connection to the system
604 bus. MON should query its service's current status and watch for
608 if me._bus is not None:
611 def _reconnect(me, hunoz = None):
613 Start connecting to the bus.
615 If we fail the first time, retry periodically.
617 if me._state == 'startup':
618 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
619 elif me._state == 'connected':
620 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
622 T.aside(SM.notify, 'conntrack', 'dbus-connection',
623 'state=%s' % me._state)
624 me._state == 'reconnecting'
626 if me._try_connect():
627 G.timeout_add_seconds(5, me._try_connect)
629 def _try_connect(me):
631 Actually make a connection attempt.
633 If we succeed, attach the monitors.
636 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
637 if addr == 'SESSION':
638 bus = D.SessionBus(mainloop = me._loop, private = True)
639 elif addr is not None:
640 bus = D.bus.BusConnection(addr, mainloop = me._loop)
642 bus = D.SystemBus(mainloop = me._loop, private = True)
645 except D.DBusException, e:
648 me._state = 'connected'
649 bus.call_on_disconnection(me._reconnect)
650 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
653 ###--------------------------------------------------------------------------
656 class GIOWatcher (object):
658 Monitor I/O events using glib.
660 def __init__(me, conn, mc = G.main_context_default()):
664 def connected(me, sock):
665 me._watch = G.io_add_watch(sock, G.IO_IN,
666 lambda *hunoz: me._conn.receive())
667 def disconnected(me):
668 G.source_remove(me._watch)
671 me._mc.iteration(True)
673 SM.iowatch = GIOWatcher(SM)
677 Service initialization.
679 Add the D-Bus monitor here, because we might send commands off immediately,
680 and we want to make sure the server connection is up.
683 T.Coroutine(kickpeers, name = 'kickpeers').switch()
685 DBM.addmon(NetworkManagerMonitor())
686 DBM.addmon(ConnManMonitor())
687 DBM.addmon(MaemoICdMonitor())
688 G.timeout_add_seconds(30, lambda: (_delay is not None or
689 netupdown(True, ['interval-timer']) or
694 Parse the command-line options.
696 Automatically changes directory to the requested configdir, and turns on
697 debugging. Returns the options object.
699 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
700 version = '%%prog %s' % VERSION)
702 op.add_option('-a', '--admin-socket',
703 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
704 help = 'Select socket to connect to [default %default]')
705 op.add_option('-d', '--directory',
706 metavar = 'DIR', dest = 'dir', default = T.configdir,
707 help = 'Select current diretory [default %default]')
708 op.add_option('-c', '--config',
709 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
710 help = 'Select configuration [default %default]')
711 op.add_option('--daemon', dest = 'daemon',
712 default = False, action = 'store_true',
713 help = 'Become a daemon after successful initialization')
714 op.add_option('--debug', dest = 'debug',
715 default = False, action = 'store_true',
716 help = 'Emit debugging trace information')
717 op.add_option('--startup', dest = 'startup',
718 default = False, action = 'store_true',
719 help = 'Being called as part of the server startup')
721 opts, args = op.parse_args()
722 if args: op.error('no arguments permitted')
724 T._debug = opts.debug
727 ## Service table, for running manually.
728 def cmd_updown(upness):
729 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
730 service_info = [('conntrack', VERSION, {
731 'up': (0, None, '', cmd_updown(True)),
732 'down': (0, None, '', cmd_updown(False)),
733 'show-config': (0, 0, '', cmd_showconfig),
734 'show-groups': (0, 0, '', cmd_showgroups),
735 'show-group': (1, 1, 'GROUP', cmd_showgroup)
738 if __name__ == '__main__':
739 opts = parse_options()
740 CF = Config(opts.conf)
741 T.runservices(opts.tripesock, service_info,
742 init = init, daemon = opts.daemon)
744 ###----- That's all, folks --------------------------------------------------