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