chiark / gitweb /
svc/conntrack.in: Monitor class for ConnMan.
[tripe] / svc / conntrack.in
1 #! @PYTHON@
2 ### -*-python-*-
3 ###
4 ### Service for automatically tracking network connection status
5 ###
6 ### (c) 2010 Straylight/Edgeware
7 ###
8
9 ###----- Licensing notice ---------------------------------------------------
10 ###
11 ### This file is part of Trivial IP Encryption (TrIPE).
12 ###
13 ### TrIPE is free software; you can redistribute it and/or modify
14 ### it under the terms of the GNU General Public License as published by
15 ### the Free Software Foundation; either version 2 of the License, or
16 ### (at your option) any later version.
17 ###
18 ### TrIPE is distributed in the hope that it will be useful,
19 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
20 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21 ### GNU General Public License for more details.
22 ###
23 ### You should have received a copy of the GNU General Public License
24 ### along with TrIPE; if not, write to the Free Software Foundation,
25 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
26
27 VERSION = '@VERSION@'
28
29 ###--------------------------------------------------------------------------
30 ### External dependencies.
31
32 from ConfigParser import RawConfigParser
33 from optparse import OptionParser
34 import os as OS
35 import sys as SYS
36 import socket as S
37 import mLib as M
38 import tripe as T
39 import dbus as D
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
46 SM = T.svcmgr
47 ##__import__('rmcr').__debug = True
48
49 ###--------------------------------------------------------------------------
50 ### Utilities.
51
52 class struct (object):
53   """A simple container object."""
54   def __init__(me, **kw):
55     me.__dict__.update(kw)
56
57 def toposort(cmp, things):
58   """
59   Generate the THINGS in an order consistent with a given partial order.
60
61   The function CMP(X, Y) should return true if X must precede Y, and false if
62   it doesn't care.  If X and Y are equal then it should return false.
63
64   The THINGS may be any finite iterable; it is converted to a list
65   internally.
66   """
67
68   ## Make sure we can index the THINGS, and prepare an ordering table.
69   ## What's going on?  The THINGS might not have a helpful equality
70   ## predicate, so it's easier to work with indices.  The ordering table will
71   ## remember which THINGS (by index) are considered greater than other
72   ## things.
73   things = list(things)
74   n = len(things)
75   order = [{} for i in xrange(n)]
76   rorder = [{} for i in xrange(n)]
77   for i in xrange(n):
78     for j in xrange(n):
79       if i != j and cmp(things[i], things[j]):
80         order[j][i] = True
81         rorder[i][j] = True
82
83   ## Now we can do the sort.
84   out = []
85   while True:
86     done = True
87     for i in xrange(n):
88       if order[i] is not None:
89         done = False
90         if len(order[i]) == 0:
91           for j in rorder[i]:
92             del order[j][i]
93           yield things[i]
94           order[i] = None
95     if done:
96       break
97
98 ###--------------------------------------------------------------------------
99 ### Parse the configuration file.
100
101 ## Hmm.  Should I try to integrate this with the peers database?  It's not a
102 ## good fit; it'd need special hacks in tripe-newpeers.  And the use case for
103 ## this service are largely going to be satellite notes, I don't think
104 ## scalability's going to be a problem.
105
106 class Config (object):
107   """
108   Represents a configuration file.
109
110   The most interesting thing is probably the `groups' slot, which stores a
111   list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
112   list of (TAG, PEER, ADDR, MASK) triples.  The implication is that there
113   should be precisely one peer with a name matching NAME-*, and that it
114   should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such
115   that the host's primary IP address (if PEER is None -- or the IP address it
116   would use for communicating with PEER) is within the network defined by
117   ADDR/MASK.
118   """
119
120   def __init__(me, file):
121     """
122     Construct a new Config object, reading the given FILE.
123     """
124     me._file = file
125     me._fwatch = M.FWatch(file)
126     me._update()
127
128   def check(me):
129     """
130     See whether the configuration file has been updated.
131     """
132     if me._fwatch.update():
133       me._update()
134
135   def _update(me):
136     """
137     Internal function to update the configuration from the underlying file.
138     """
139
140     ## Read the configuration.  We have no need of the fancy substitutions,
141     ## so turn them all off.
142     cp = RawConfigParser()
143     cp.read(me._file)
144     if T._debug: print '# reread config'
145
146     ## Save the test address.  Make sure it's vaguely sensible.  The default
147     ## is probably good for most cases, in fact, since that address isn't
148     ## actually in use.  Note that we never send packets to the test address;
149     ## we just use it to discover routing information.
150     if cp.has_option('DEFAULT', 'test-addr'):
151       testaddr = cp.get('DEFAULT', 'test-addr')
152       S.inet_aton(testaddr)
153     else:
154       testaddr = '1.2.3.4'
155
156     ## Scan the configuration file and build the groups structure.
157     groups = []
158     for sec in cp.sections():
159       pats = []
160       for tag in cp.options(sec):
161         spec = cp.get(sec, tag).split()
162
163         ## Parse the entry into peer and network.
164         if len(spec) == 1:
165           peer = None
166           net = spec[0]
167         else:
168           peer, net = spec
169
170         ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
171         ## and MASK is either a dotted-quad or a single integer N indicating
172         ## a mask with N leading ones followed by trailing zeroes.
173         slash = net.index('/')
174         addr, = unpack('>L', S.inet_aton(net[:slash]))
175         if net.find('.', slash + 1) >= 0:
176           mask, = unpack('>L', S.inet_aton(net[:slash]))
177         else:
178           n = int(net[slash + 1:], 10)
179           mask = (1 << 32) - (1 << 32 - n)
180         pats.append((tag, peer, addr & mask, mask))
181
182       ## Annoyingly, RawConfigParser doesn't preserve the order of options.
183       ## In order to make things vaguely sane, we topologically sort the
184       ## patterns so that more specific patterns are checked first.
185       pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
186                              (p and not pp) or \
187                              (p == pp and m == (m | mm) and aa == (a & mm)),
188                            pats))
189       groups.append((sec, pats))
190
191     ## Done.
192     me.testaddr = testaddr
193     me.groups = groups
194
195 ### This will be a configuration file.
196 CF = None
197
198 def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
199 def strmask(m):
200   for i in xrange(33):
201     if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return i
202   return straddr(m)
203
204 def cmd_showconfig():
205   T.svcinfo('test-addr=%s' % CF.testaddr)
206 def cmd_showgroups():
207   for sec, pats in CF.groups:
208     T.svcinfo(sec)
209 def cmd_showgroup(g):
210   for s, p in CF.groups:
211     if s == g:
212       pats = p
213       break
214   else:
215     raise T.TripeJobError, 'unknown-group', g
216   for t, p, a, m in pats:
217     T.svcinfo('peer', t,
218               'target', p or '(default)',
219               'net', '%s/%s' % (straddr(a), strmask(m)))
220
221 ###--------------------------------------------------------------------------
222 ### Responding to a network up/down event.
223
224 def localaddr(peer):
225   """
226   Return the local IP address used for talking to PEER.
227   """
228   sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
229   try:
230     try:
231       sk.connect((peer, 1))
232       addr, _ = sk.getsockname()
233       addr, = unpack('>L', S.inet_aton(addr))
234       return addr
235     except S.error:
236       return None
237   finally:
238     sk.close()
239
240 _kick = T.Queue()
241 def kickpeers():
242   while True:
243     upness, reason = _kick.get()
244     if T._debug: print '# kickpeers %s: %s' % (upness, reason)
245     select = []
246
247     ## Make sure the configuration file is up-to-date.  Don't worry if we
248     ## can't do anything useful.
249     try:
250       CF.check()
251     except Exception, exc:
252       SM.warn('conntrack', 'config-file-error',
253               exc.__class__.__name__, str(exc))
254
255     ## Find the current list of peers.
256     peers = SM.list()
257
258     ## Work out the primary IP address.
259     if upness:
260       addr = localaddr(CF.testaddr)
261       if addr is None:
262         upness = False
263     else:
264       addr = None
265     if not T._debug: pass
266     elif addr: print '#   local address = %s' % straddr(addr)
267     else: print '#   offline'
268
269     ## Now decide what to do.
270     changes = []
271     for g, pp in CF.groups:
272       if T._debug: print '#   check group %s' % g
273
274       ## Find out which peer in the group ought to be active.
275       ip = None
276       map = {}
277       want = None
278       for t, p, a, m in pp:
279         if p is None or not upness:
280           ipq = addr
281         else:
282           ipq = localaddr(p)
283         if T._debug:
284           info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
285             t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
286         if upness and ip is None and \
287               ipq is not None and (ipq & m) == a:
288           if T._debug: print '#     %s: SELECTED' % info
289           map[t] = 'up'
290           select.append('%s=%s' % (g, t))
291           if t == 'down' or t.startswith('down/'):
292             want = None
293           else:
294             want = t
295           ip = ipq
296         else:
297           map[t] = 'down'
298           if T._debug: print '#     %s: skipped' % info
299
300       ## Shut down the wrong ones.
301       found = False
302       if T._debug: print '#   peer-map = %r' % map
303       for p in peers:
304         what = map.get(p, 'leave')
305         if what == 'up':
306           found = True
307           if T._debug: print '#   peer %s: already up' % p
308         elif what == 'down':
309           def _(p = p):
310             try:
311               SM.kill(p)
312             except T.TripeError, exc:
313               if exc.args[0] == 'unknown-peer':
314                 ## Inherently racy; don't worry about this.
315                 pass
316               else:
317                 raise
318           if T._debug: print '#   peer %s: bring down' % p
319           changes.append(_)
320
321       ## Start the right one if necessary.
322       if want is not None and not found:
323         def _(want = want):
324           try:
325             SM.svcsubmit('connect', 'active', want)
326           except T.TripeError, exc:
327             SM.warn('conntrack', 'connect-failed', want, *exc.args)
328         if T._debug: print '#   peer %s: bring up' % want
329         changes.append(_)
330
331     ## Commit the changes.
332     if changes:
333       SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
334       for c in changes: c()
335
336 def netupdown(upness, reason):
337   """
338   Add or kill peers according to whether the network is up or down.
339
340   UPNESS is true if the network is up, or false if it's down.
341   """
342
343   _kick.put((upness, reason))
344
345 ###--------------------------------------------------------------------------
346 ### NetworkManager monitor.
347
348 NM_NAME = 'org.freedesktop.NetworkManager'
349 NM_PATH = '/org/freedesktop/NetworkManager'
350 NM_IFACE = NM_NAME
351 NMCA_IFACE = NM_NAME + '.Connection.Active'
352
353 NM_STATE_CONNECTED = 3 #obsolete
354 NM_STATE_CONNECTED_LOCAL = 50
355 NM_STATE_CONNECTED_SITE = 60
356 NM_STATE_CONNECTED_GLOBAL = 70
357 NM_CONNSTATES = set([NM_STATE_CONNECTED,
358                      NM_STATE_CONNECTED_LOCAL,
359                      NM_STATE_CONNECTED_SITE,
360                      NM_STATE_CONNECTED_GLOBAL])
361
362 class NetworkManagerMonitor (object):
363   """
364   Watch NetworkManager signals for changes in network state.
365   """
366
367   ## Strategy.  There are two kinds of interesting state transitions for us.
368   ## The first one is the global are-we-connected state, which we'll use to
369   ## toggle network upness on a global level.  The second is which connection
370   ## has the default route, which we'll use to tweak which peer in the peer
371   ## group is active.  The former is most easily tracked using the signal
372   ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
373   ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
374   ## look for when a new connection gains the default route.
375
376   def attach(me, bus):
377     try:
378       nm = bus.get_object(NM_NAME, NM_PATH)
379       state = nm.Get(NM_IFACE, 'State')
380       if state in NM_CONNSTATES:
381         netupdown(True, ['nm', 'initially-connected'])
382       else:
383         netupdown(False, ['nm', 'initially-disconnected'])
384     except D.DBusException:
385       pass
386     bus.add_signal_receiver(me._nm_state, 'StateChanged',
387                             NM_IFACE, NM_NAME, NM_PATH)
388     bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
389                             NMCA_IFACE, NM_NAME, None)
390
391   def _nm_state(me, state):
392     if state in NM_CONNSTATES:
393       netupdown(True, ['nm', 'connected'])
394     else:
395       netupdown(False, ['nm', 'disconnected'])
396
397   def _nm_connchange(me, props):
398     if props.get('Default', False):
399       netupdown(True, ['nm', 'default-connection-change'])
400
401 ##--------------------------------------------------------------------------
402 ### Connman monitor.
403
404 CM_NAME = 'net.connman'
405 CM_PATH = '/'
406 CM_IFACE = 'net.connman.Manager'
407
408 class ConnManMonitor (object):
409   """
410   Watch ConnMan signls for changes in network state.
411   """
412
413   ## Strategy.  Everything seems to be usefully encoded in the `State'
414   ## property.  If it's `offline', `idle' or `ready' then we don't expect a
415   ## network connection.  During handover from one network to another, the
416   ## property passes through `ready' to `online'.
417
418   def attach(me, bus):
419     try:
420       cm = bus.get_object(CM_NAME, CM_PATH)
421       props = cm.GetProperties(dbus_interface = CM_IFACE)
422       state = props['State']
423       netupdown(state == 'online', ['connman', 'initially-%s' % state])
424     except D.DBusException:
425       pass
426     bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
427                             CM_IFACE, CM_NAME, CM_PATH)
428
429   def _cm_state(me, prop, value):
430     if prop != 'State': return
431     netupdown(value == 'online', ['connman', value])
432
433 ###--------------------------------------------------------------------------
434 ### Maemo monitor.
435
436 ICD_NAME = 'com.nokia.icd'
437 ICD_PATH = '/com/nokia/icd'
438 ICD_IFACE = ICD_NAME
439
440 class MaemoICdMonitor (object):
441   """
442   Watch ICd signals for changes in network state.
443   """
444
445   ## Strategy.  ICd only handles one connection at a time in steady state,
446   ## though when switching between connections, it tries to bring the new one
447   ## up before shutting down the old one.  This makes life a bit easier than
448   ## it is with NetworkManager.  On the other hand, the notifications are
449   ## relative to particular connections only, and the indicator that the old
450   ## connection is down (`IDLE') comes /after/ the new one comes up
451   ## (`CONNECTED'), so we have to remember which one is active.
452
453   def attach(me, bus):
454     try:
455       icd = bus.get_object(ICD_NAME, ICD_PATH)
456       try:
457         iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
458         me._iap = iap
459         netupdown(True, ['icd', 'initially-connected', iap])
460       except D.DBusException:
461         me._iap = None
462         netupdown(False, ['icd', 'initially-disconnected'])
463     except D.DBusException:
464       me._iap = None
465     bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
466                             ICD_NAME, ICD_PATH)
467
468   def _icd_state(me, iap, ty, state, hunoz):
469     if state == 'CONNECTED':
470       me._iap = iap
471       netupdown(True, ['icd', 'connected', iap])
472     elif state == 'IDLE' and iap == me._iap:
473       me._iap = None
474       netupdown(False, ['icd', 'idle'])
475
476 ###--------------------------------------------------------------------------
477 ### D-Bus connection tracking.
478
479 class DBusMonitor (object):
480   """
481   Maintains a connection to the system D-Bus, and watches for signals.
482
483   If the connection is initially down, or drops for some reason, we retry
484   periodically (every five seconds at the moment).  If the connection
485   resurfaces, we reattach the monitors.
486   """
487
488   def __init__(me):
489     """
490     Initialise the object and try to establish a connection to the bus.
491     """
492     me._mons = []
493     me._loop = D.mainloop.glib.DBusGMainLoop()
494     me._state = 'startup'
495     me._reconnect()
496
497   def addmon(me, mon):
498     """
499     Add a monitor object to watch for signals.
500
501     MON.attach(BUS) is called, with BUS being the connection to the system
502     bus.  MON should query its service's current status and watch for
503     relevant signals.
504     """
505     me._mons.append(mon)
506     if me._bus is not None:
507       mon.attach(me._bus)
508
509   def _reconnect(me, hunoz = None):
510     """
511     Start connecting to the bus.
512
513     If we fail the first time, retry periodically.
514     """
515     if me._state == 'startup':
516       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
517     elif me._state == 'connected':
518       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
519     else:
520       T.aside(SM.notify, 'conntrack', 'dbus-connection',
521               'state=%s' % me._state)
522     me._state == 'reconnecting'
523     me._bus = None
524     if me._try_connect():
525       G.timeout_add_seconds(5, me._try_connect)
526
527   def _try_connect(me):
528     """
529     Actually make a connection attempt.
530
531     If we succeed, attach the monitors.
532     """
533     try:
534       addr = OS.getenv('TRIPE_CONNTRACK_BUS')
535       if addr == 'SESSION':
536         bus = D.SessionBus(mainloop = me._loop, private = True)
537       elif addr is not None:
538         bus = D.bus.BusConnection(addr, mainloop = me._loop)
539       else:
540         bus = D.SystemBus(mainloop = me._loop, private = True)
541       for m in me._mons:
542         m.attach(bus)
543     except D.DBusException, e:
544       return True
545     me._bus = bus
546     me._state = 'connected'
547     bus.call_on_disconnection(me._reconnect)
548     T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
549     return False
550
551 ###--------------------------------------------------------------------------
552 ### TrIPE service.
553
554 class GIOWatcher (object):
555   """
556   Monitor I/O events using glib.
557   """
558   def __init__(me, conn, mc = G.main_context_default()):
559     me._conn = conn
560     me._watch = None
561     me._mc = mc
562   def connected(me, sock):
563     me._watch = G.io_add_watch(sock, G.IO_IN,
564                                lambda *hunoz: me._conn.receive())
565   def disconnected(me):
566     G.source_remove(me._watch)
567     me._watch = None
568   def iterate(me):
569     me._mc.iteration(True)
570
571 SM.iowatch = GIOWatcher(SM)
572
573 def init():
574   """
575   Service initialization.
576
577   Add the D-Bus monitor here, because we might send commands off immediately,
578   and we want to make sure the server connection is up.
579   """
580   global DBM
581   T.Coroutine(kickpeers, name = 'kickpeers').switch()
582   DBM = DBusMonitor()
583   DBM.addmon(NetworkManagerMonitor())
584   DBM.addmon(ConnManMonitor())
585   DBM.addmon(MaemoICdMonitor())
586   G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
587                                      or True))
588
589 def parse_options():
590   """
591   Parse the command-line options.
592
593   Automatically changes directory to the requested configdir, and turns on
594   debugging.  Returns the options object.
595   """
596   op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
597                     version = '%%prog %s' % VERSION)
598
599   op.add_option('-a', '--admin-socket',
600                 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
601                 help = 'Select socket to connect to [default %default]')
602   op.add_option('-d', '--directory',
603                 metavar = 'DIR', dest = 'dir', default = T.configdir,
604                 help = 'Select current diretory [default %default]')
605   op.add_option('-c', '--config',
606                 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
607                 help = 'Select configuration [default %default]')
608   op.add_option('--daemon', dest = 'daemon',
609                 default = False, action = 'store_true',
610                 help = 'Become a daemon after successful initialization')
611   op.add_option('--debug', dest = 'debug',
612                 default = False, action = 'store_true',
613                 help = 'Emit debugging trace information')
614   op.add_option('--startup', dest = 'startup',
615                 default = False, action = 'store_true',
616                 help = 'Being called as part of the server startup')
617
618   opts, args = op.parse_args()
619   if args: op.error('no arguments permitted')
620   OS.chdir(opts.dir)
621   T._debug = opts.debug
622   return opts
623
624 ## Service table, for running manually.
625 def cmd_updown(upness):
626   return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
627 service_info = [('conntrack', VERSION, {
628   'up': (0, None, '', cmd_updown(True)),
629   'down': (0, None, '', cmd_updown(False)),
630   'show-config': (0, 0, '', cmd_showconfig),
631   'show-groups': (0, 0, '', cmd_showgroups),
632   'show-group': (1, 1, 'GROUP', cmd_showgroup)
633 })]
634
635 if __name__ == '__main__':
636   opts = parse_options()
637   CF = Config(opts.conf)
638   T.runservices(opts.tripesock, service_info,
639                 init = init, daemon = opts.daemon)
640
641 ###----- That's all, folks --------------------------------------------------