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):
61 def __init__(me, addrstr, maskstr = None):
62 me.addr = me._addrstr_to_int(addrstr)
65 elif maskstr.isdigit():
66 me.mask = (1 << 32) - (1 << 32 - int(maskstr))
68 me.mask = me._addrstr_to_int(maskstr)
70 raise ValueError('network contains bits set beyond mask')
71 def _addrstr_to_int(me, addrstr):
72 return unpack('>L', S.inet_aton(addrstr))[0]
73 def _int_to_addrstr(me, n):
74 return S.inet_ntoa(pack('>L', n))
75 def sockaddr(me, port = 0):
76 if me.mask != -1: raise ValueError('not a simple address')
77 return me._int_to_addrstr(me.addr), port
79 addrstr = me._int_to_addrstr(me.addr)
83 inv = me.mask ^ ((1 << 32) - 1)
84 if (inv&(inv + 1)) == 0:
85 return '%s/%d' % (addrstr, 32 - inv.bit_length())
87 return '%s/%s' % (addrstr, me._int_to_addrstr(me.mask))
89 if (me.mask&net.mask) != net.mask: return False
90 if (me.addr ^ net.addr)&net.mask: return False
93 if me.mask != other.mask: return False
94 if me.addr != other.addr: return False
97 def from_sockaddr(cls, sa):
98 addr, port = (lambda a, p: (a, p))(*sa)
99 return cls(addr), port
101 def parse_address(addrstr, maskstr = None):
102 return InetAddress(addrstr, maskstr)
104 def parse_net(netstr):
105 try: sl = netstr.index('/')
106 except ValueError: raise ValueError('missing mask')
107 return parse_address(netstr[:sl], netstr[sl + 1:])
109 def straddr(a): return a is None and '#<none>' or str(a)
111 ###--------------------------------------------------------------------------
112 ### Parse the configuration file.
114 ## Hmm. Should I try to integrate this with the peers database? It's not a
115 ## good fit; it'd need special hacks in tripe-newpeers. And the use case for
116 ## this service are largely going to be satellite notes, I don't think
117 ## scalability's going to be a problem.
119 TESTADDR = InetAddress('1.2.3.4')
122 ('COMMENT', RX.compile(r'^\s*($|[;#])')),
123 ('GRPHDR', RX.compile(r'^\s*\[(.*)\]\s*$')),
124 ('ASSGN', RX.compile(r'\s*([\w.-]+)\s*[:=]\s*(|\S|\S.*\S)\s*$'))]
126 class ConfigError (Exception):
127 def __init__(me, file, lno, msg):
132 return '%s:%d: %s' % (me.file, me.lno, me.msg)
134 class Config (object):
136 Represents a configuration file.
138 The most interesting thing is probably the `groups' slot, which stores a
139 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
140 list of (TAG, PEER, NETS) triples. The implication is that there should be
141 precisely one peer from the set, and that it should be named TAG, where
142 (TAG, PEER, NETS) is the first triple such that the host's primary IP
143 address (if PEER is None -- or the IP address it would use for
144 communicating with PEER) is within one of the networks defined by NETS.
147 def __init__(me, file):
149 Construct a new Config object, reading the given FILE.
152 me._fwatch = M.FWatch(file)
157 See whether the configuration file has been updated.
159 if me._fwatch.update():
164 Internal function to update the configuration from the underlying file.
167 if T._debug: print '# reread config'
175 ## Open the file and start reading.
176 with open(me._file) as f:
180 for tag, rx in CONFSYNTAX:
184 raise ConfigError(me._file, lno, 'failed to parse line: %r' % line)
187 ## A comment. Ignore it and hope it goes away.
191 elif tag == 'GRPHDR':
192 ## A group header. Flush the old group and start a new one.
195 if grpname is not None: groups[grpname] = grplist
196 if newname in groups:
197 raise ConfigError(me._file, lno,
198 "duplicate group name `%s'" % newname)
203 ## An assignment. Deal with it.
204 name, value = m.group(1), m.group(2)
207 ## We're outside of any group, so this is a global configuration
210 if name == 'test-addr':
211 for astr in value.split():
213 a = parse_address(astr)
215 raise ConfigError(me._file, lno,
216 "invalid IP address `%s': %s" %
218 if testaddr is not None:
219 raise ConfigError(me._file, lno, 'duplicate test-address')
222 raise ConfigError(me._file, lno,
223 "unknown global option `%s'" % name)
226 ## Parse a pattern and add it to the group.
230 ## Check for an explicit target address.
231 if i >= len(spec) or spec[i].find('/') >= 0:
235 peer = parse_address(spec[i])
237 raise ConfigError(me._file, lno,
238 "invalid IP address `%s': %s" %
242 ## Parse the list of local networks.
246 net = parse_net(spec[i])
248 raise ConfigError(me._file, lno,
249 "invalid IP network `%s': %s" %
255 raise ConfigError(me._file, lno, 'no networks defined')
257 ## Add this entry to the list.
258 grplist.append((name, peer, nets))
260 ## Fill in the default test address if necessary.
261 if testaddr is None: testaddr = TESTADDR
264 if grpname is not None: groups[grpname] = grplist
265 me.testaddr = testaddr
268 ### This will be a configuration file.
271 def cmd_showconfig():
272 T.svcinfo('test-addr=%s' % CF.testaddr)
273 def cmd_showgroups():
274 for g in sorted(CF.groups.iterkeys()):
276 def cmd_showgroup(g):
277 try: pats = CF.groups[g]
278 except KeyError: raise T.TripeJobError('unknown-group', g)
279 for t, p, nn in pats:
281 'target', p and str(p) or '(default)',
282 'net', ' '.join(map(str, nn)))
284 ###--------------------------------------------------------------------------
285 ### Responding to a network up/down event.
289 Return the local IP address used for talking to PEER.
291 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
294 sk.connect(peer.sockaddr(1))
295 addr = sk.getsockname()
296 return InetAddress.from_sockaddr(addr)[0]
307 if _delay is not None:
308 if T._debug: print '# cancel delayed kick'
309 G.source_remove(_delay)
312 def netupdown(upness, reason):
314 Add or kill peers according to whether the network is up or down.
316 UPNESS is true if the network is up, or false if it's down.
319 _kick.put((upness, reason))
321 def delay_netupdown(upness, reason):
326 if T._debug: print '# delayed %s: %s' % (upness, reason)
328 netupdown(upness, reason)
330 if T._debug: print '# delaying %s: %s' % (upness, reason)
331 _delay = G.timeout_add(2000, _func)
335 upness, reason = _kick.get()
336 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
340 ## Make sure the configuration file is up-to-date. Don't worry if we
341 ## can't do anything useful.
344 except Exception, exc:
345 SM.warn('conntrack', 'config-file-error',
346 exc.__class__.__name__, str(exc))
348 ## Find the current list of peers.
351 ## Work out the primary IP address.
353 addr = localaddr(CF.testaddr)
358 if not T._debug: pass
359 elif addr: print '# local address = %s' % straddr(addr)
360 else: print '# offline'
362 ## Now decide what to do.
364 for g, pp in CF.groups.iteritems():
365 if T._debug: print '# check group %s' % g
367 ## Find out which peer in the group ought to be active.
372 if p is None or not upness: ip = addr
373 else: ip = localaddr(p)
375 info = 'peer = %s; target = %s; nets = %s; local = %s' % (
376 t, p or '(default)', ', '.join(map(str, nn)), straddr(ip))
377 if upness and not matchp and \
378 ip is not None and any(ip.withinp(n) for n in nn):
379 if T._debug: print '# %s: SELECTED' % info
381 select.append('%s=%s' % (g, t))
382 if t == 'down' or t.startswith('down/'): want = None
387 if T._debug: print '# %s: skipped' % info
389 ## Shut down the wrong ones.
391 if T._debug: print '# peer-map = %r' % statemap
393 what = statemap.get(p, 'leave')
396 if T._debug: print '# peer %s: already up' % p
401 except T.TripeError, exc:
402 if exc.args[0] == 'unknown-peer':
403 ## Inherently racy; don't worry about this.
407 if T._debug: print '# peer %s: bring down' % p
410 ## Start the right one if necessary.
411 if want is not None and not found:
414 list(SM.svcsubmit('connect', 'active', want))
415 except T.TripeError, exc:
416 SM.warn('conntrack', 'connect-failed', want, *exc.args)
417 if T._debug: print '# peer %s: bring up' % want
420 ## Commit the changes.
422 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
423 for c in changes: c()
425 ###--------------------------------------------------------------------------
426 ### NetworkManager monitor.
428 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
430 NM_NAME = 'org.freedesktop.NetworkManager'
431 NM_PATH = '/org/freedesktop/NetworkManager'
433 NMCA_IFACE = NM_NAME + '.Connection.Active'
435 NM_STATE_CONNECTED = 3 #obsolete
436 NM_STATE_CONNECTED_LOCAL = 50
437 NM_STATE_CONNECTED_SITE = 60
438 NM_STATE_CONNECTED_GLOBAL = 70
439 NM_CONNSTATES = set([NM_STATE_CONNECTED,
440 NM_STATE_CONNECTED_LOCAL,
441 NM_STATE_CONNECTED_SITE,
442 NM_STATE_CONNECTED_GLOBAL])
444 class NetworkManagerMonitor (object):
446 Watch NetworkManager signals for changes in network state.
449 ## Strategy. There are two kinds of interesting state transitions for us.
450 ## The first one is the global are-we-connected state, which we'll use to
451 ## toggle network upness on a global level. The second is which connection
452 ## has the default route, which we'll use to tweak which peer in the peer
453 ## group is active. The former is most easily tracked using the signal
454 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
455 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
456 ## look for when a new connection gains the default route.
460 nm = bus.get_object(NM_NAME, NM_PATH)
461 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
462 if state in NM_CONNSTATES:
463 netupdown(True, ['nm', 'initially-connected'])
465 netupdown(False, ['nm', 'initially-disconnected'])
466 except D.DBusException, e:
467 if T._debug: print '# exception attaching to network-manager: %s' % e
468 bus.add_signal_receiver(me._nm_state, 'StateChanged',
469 NM_IFACE, NM_NAME, NM_PATH)
470 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
471 NMCA_IFACE, NM_NAME, None)
473 def _nm_state(me, state):
474 if state in NM_CONNSTATES:
475 delay_netupdown(True, ['nm', 'connected'])
477 delay_netupdown(False, ['nm', 'disconnected'])
479 def _nm_connchange(me, props):
480 if props.get('Default', False) or props.get('Default6', False):
481 delay_netupdown(True, ['nm', 'default-connection-change'])
483 ##--------------------------------------------------------------------------
486 CM_NAME = 'net.connman'
488 CM_IFACE = 'net.connman.Manager'
490 class ConnManMonitor (object):
492 Watch ConnMan signls for changes in network state.
495 ## Strategy. Everything seems to be usefully encoded in the `State'
496 ## property. If it's `offline', `idle' or `ready' then we don't expect a
497 ## network connection. During handover from one network to another, the
498 ## property passes through `ready' to `online'.
502 cm = bus.get_object(CM_NAME, CM_PATH)
503 props = cm.GetProperties(dbus_interface = CM_IFACE)
504 state = props['State']
505 netupdown(state == 'online', ['connman', 'initially-%s' % state])
506 except D.DBusException, e:
507 if T._debug: print '# exception attaching to connman: %s' % e
508 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
509 CM_IFACE, CM_NAME, CM_PATH)
511 def _cm_state(me, prop, value):
512 if prop != 'State': return
513 delay_netupdown(value == 'online', ['connman', value])
515 ###--------------------------------------------------------------------------
518 ICD_NAME = 'com.nokia.icd'
519 ICD_PATH = '/com/nokia/icd'
522 class MaemoICdMonitor (object):
524 Watch ICd signals for changes in network state.
527 ## Strategy. ICd only handles one connection at a time in steady state,
528 ## though when switching between connections, it tries to bring the new one
529 ## up before shutting down the old one. This makes life a bit easier than
530 ## it is with NetworkManager. On the other hand, the notifications are
531 ## relative to particular connections only, and the indicator that the old
532 ## connection is down (`IDLE') comes /after/ the new one comes up
533 ## (`CONNECTED'), so we have to remember which one is active.
537 icd = bus.get_object(ICD_NAME, ICD_PATH)
539 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
541 netupdown(True, ['icd', 'initially-connected', iap])
542 except D.DBusException:
544 netupdown(False, ['icd', 'initially-disconnected'])
545 except D.DBusException, e:
546 if T._debug: print '# exception attaching to icd: %s' % e
548 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
551 def _icd_state(me, iap, ty, state, hunoz):
552 if state == 'CONNECTED':
554 delay_netupdown(True, ['icd', 'connected', iap])
555 elif state == 'IDLE' and iap == me._iap:
557 delay_netupdown(False, ['icd', 'idle'])
559 ###--------------------------------------------------------------------------
560 ### D-Bus connection tracking.
562 class DBusMonitor (object):
564 Maintains a connection to the system D-Bus, and watches for signals.
566 If the connection is initially down, or drops for some reason, we retry
567 periodically (every five seconds at the moment). If the connection
568 resurfaces, we reattach the monitors.
573 Initialise the object and try to establish a connection to the bus.
576 me._loop = D.mainloop.glib.DBusGMainLoop()
577 me._state = 'startup'
582 Add a monitor object to watch for signals.
584 MON.attach(BUS) is called, with BUS being the connection to the system
585 bus. MON should query its service's current status and watch for
589 if me._bus is not None:
592 def _reconnect(me, hunoz = None):
594 Start connecting to the bus.
596 If we fail the first time, retry periodically.
598 if me._state == 'startup':
599 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
600 elif me._state == 'connected':
601 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
603 T.aside(SM.notify, 'conntrack', 'dbus-connection',
604 'state=%s' % me._state)
605 me._state == 'reconnecting'
607 if me._try_connect():
608 G.timeout_add_seconds(5, me._try_connect)
610 def _try_connect(me):
612 Actually make a connection attempt.
614 If we succeed, attach the monitors.
617 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
618 if addr == 'SESSION':
619 bus = D.SessionBus(mainloop = me._loop, private = True)
620 elif addr is not None:
621 bus = D.bus.BusConnection(addr, mainloop = me._loop)
623 bus = D.SystemBus(mainloop = me._loop, private = True)
626 except D.DBusException, e:
629 me._state = 'connected'
630 bus.call_on_disconnection(me._reconnect)
631 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
634 ###--------------------------------------------------------------------------
637 class GIOWatcher (object):
639 Monitor I/O events using glib.
641 def __init__(me, conn, mc = G.main_context_default()):
645 def connected(me, sock):
646 me._watch = G.io_add_watch(sock, G.IO_IN,
647 lambda *hunoz: me._conn.receive())
648 def disconnected(me):
649 G.source_remove(me._watch)
652 me._mc.iteration(True)
654 SM.iowatch = GIOWatcher(SM)
658 Service initialization.
660 Add the D-Bus monitor here, because we might send commands off immediately,
661 and we want to make sure the server connection is up.
664 T.Coroutine(kickpeers, name = 'kickpeers').switch()
666 DBM.addmon(NetworkManagerMonitor())
667 DBM.addmon(ConnManMonitor())
668 DBM.addmon(MaemoICdMonitor())
669 G.timeout_add_seconds(30, lambda: (_delay is not None or
670 netupdown(True, ['interval-timer']) or
675 Parse the command-line options.
677 Automatically changes directory to the requested configdir, and turns on
678 debugging. Returns the options object.
680 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
681 version = '%%prog %s' % VERSION)
683 op.add_option('-a', '--admin-socket',
684 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
685 help = 'Select socket to connect to [default %default]')
686 op.add_option('-d', '--directory',
687 metavar = 'DIR', dest = 'dir', default = T.configdir,
688 help = 'Select current diretory [default %default]')
689 op.add_option('-c', '--config',
690 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
691 help = 'Select configuration [default %default]')
692 op.add_option('--daemon', dest = 'daemon',
693 default = False, action = 'store_true',
694 help = 'Become a daemon after successful initialization')
695 op.add_option('--debug', dest = 'debug',
696 default = False, action = 'store_true',
697 help = 'Emit debugging trace information')
698 op.add_option('--startup', dest = 'startup',
699 default = False, action = 'store_true',
700 help = 'Being called as part of the server startup')
702 opts, args = op.parse_args()
703 if args: op.error('no arguments permitted')
705 T._debug = opts.debug
708 ## Service table, for running manually.
709 def cmd_updown(upness):
710 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
711 service_info = [('conntrack', VERSION, {
712 'up': (0, None, '', cmd_updown(True)),
713 'down': (0, None, '', cmd_updown(False)),
714 'show-config': (0, 0, '', cmd_showconfig),
715 'show-groups': (0, 0, '', cmd_showgroups),
716 'show-group': (1, 1, 'GROUP', cmd_showgroup)
719 if __name__ == '__main__':
720 opts = parse_options()
721 CF = Config(opts.conf)
722 T.runservices(opts.tripesock, service_info,
723 init = init, daemon = opts.daemon)
725 ###----- That's all, folks --------------------------------------------------