chiark / gitweb /
Merge branch 'master' of git.distorted.org.uk:~mdw/publish/public-git/tripe
[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 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 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
353
354 class NetworkManagerMonitor (object):
355   """
356   Watch NetworkManager signals for changes in network state.
357   """
358
359   ## Strategy.  There are two kinds of interesting state transitions for us.
360   ## The first one is the global are-we-connected state, which we'll use to
361   ## toggle network upness on a global level.  The second is which connection
362   ## has the default route, which we'll use to tweak which peer in the peer
363   ## group is active.  The former is most easily tracked using the signal
364   ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
365   ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
366   ## look for when a new connection gains the default route.
367
368   def attach(me, bus):
369     try:
370       nm = bus.get_object(NM_NAME, NM_PATH)
371       state = nm.Get(NM_IFACE, 'State')
372       if state == NM_STATE_CONNECTED:
373         netupdown(True, ['nm', 'initially-connected'])
374       else:
375         netupdown(False, ['nm', 'initially-disconnected'])
376     except D.DBusException:
377       pass
378     bus.add_signal_receiver(me._nm_state, 'StateChanged', NM_IFACE,
379                             NM_NAME, NM_PATH)
380     bus.add_signal_receiver(me._nm_connchange,
381                             'PropertiesChanged', NMCA_IFACE,
382                             NM_NAME, None)
383
384   def _nm_state(me, state):
385     if state == NM_STATE_CONNECTED:
386       netupdown(True, ['nm', 'connected'])
387     else:
388       netupdown(False, ['nm', 'disconnected'])
389
390   def _nm_connchange(me, props):
391     if props.get('Default', False):
392       netupdown(True, ['nm', 'default-connection-change'])
393
394 ###--------------------------------------------------------------------------
395 ### Maemo monitor.
396
397 ICD_NAME = 'com.nokia.icd'
398 ICD_PATH = '/com/nokia/icd'
399 ICD_IFACE = ICD_NAME
400
401 class MaemoICdMonitor (object):
402   """
403   Watch ICd signals for changes in network state.
404   """
405
406   ## Strategy.  ICd only handles one connection at a time in steady state,
407   ## though when switching between connections, it tries to bring the new one
408   ## up before shutting down the old one.  This makes life a bit easier than
409   ## it is with NetworkManager.  On the other hand, the notifications are
410   ## relative to particular connections only, and the indicator that the old
411   ## connection is down (`IDLE') comes /after/ the new one comes up
412   ## (`CONNECTED'), so we have to remember which one is active.
413
414   def attach(me, bus):
415     try:
416       icd = bus.get_object(ICD_NAME, ICD_PATH)
417       try:
418         iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
419         me._iap = iap
420         netupdown(True, ['icd', 'initially-connected', iap])
421       except D.DBusException:
422         me._iap = None
423         netupdown(False, ['icd', 'initially-disconnected'])
424     except D.DBusException:
425       me._iap = None
426     bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
427                             ICD_NAME, ICD_PATH)
428
429   def _icd_state(me, iap, ty, state, hunoz):
430     if state == 'CONNECTED':
431       me._iap = iap
432       netupdown(True, ['icd', 'connected', iap])
433     elif state == 'IDLE' and iap == me._iap:
434       me._iap = None
435       netupdown(False, ['icd', 'idle'])
436
437 ###--------------------------------------------------------------------------
438 ### D-Bus connection tracking.
439
440 class DBusMonitor (object):
441   """
442   Maintains a connection to the system D-Bus, and watches for signals.
443
444   If the connection is initially down, or drops for some reason, we retry
445   periodically (every five seconds at the moment).  If the connection
446   resurfaces, we reattach the monitors.
447   """
448
449   def __init__(me):
450     """
451     Initialise the object and try to establish a connection to the bus.
452     """
453     me._mons = []
454     me._loop = D.mainloop.glib.DBusGMainLoop()
455     me._state = 'startup'
456     me._reconnect()
457
458   def addmon(me, mon):
459     """
460     Add a monitor object to watch for signals.
461
462     MON.attach(BUS) is called, with BUS being the connection to the system
463     bus.  MON should query its service's current status and watch for
464     relevant signals.
465     """
466     me._mons.append(mon)
467     if me._bus is not None:
468       mon.attach(me._bus)
469
470   def _reconnect(me, hunoz = None):
471     """
472     Start connecting to the bus.
473
474     If we fail the first time, retry periodically.
475     """
476     if me._state == 'startup':
477       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
478     elif me._state == 'connected':
479       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
480     else:
481       T.aside(SM.notify, 'conntrack', 'dbus-connection',
482               'state=%s' % me._state)
483     me._state == 'reconnecting'
484     me._bus = None
485     if me._try_connect():
486       G.timeout_add_seconds(5, me._try_connect)
487
488   def _try_connect(me):
489     """
490     Actually make a connection attempt.
491
492     If we succeed, attach the monitors.
493     """
494     try:
495       addr = OS.getenv('TRIPE_CONNTRACK_BUS')
496       if addr == 'SESSION':
497         bus = D.SessionBus(mainloop = me._loop, private = True)
498       elif addr is not None:
499         bus = D.bus.BusConnection(addr, mainloop = me._loop)
500       else:
501         bus = D.SystemBus(mainloop = me._loop, private = True)
502       for m in me._mons:
503         m.attach(bus)
504     except D.DBusException, e:
505       return True
506     me._bus = bus
507     me._state = 'connected'
508     bus.call_on_disconnection(me._reconnect)
509     T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
510     return False
511
512 ###--------------------------------------------------------------------------
513 ### TrIPE service.
514
515 class GIOWatcher (object):
516   """
517   Monitor I/O events using glib.
518   """
519   def __init__(me, conn, mc = G.main_context_default()):
520     me._conn = conn
521     me._watch = None
522     me._mc = mc
523   def connected(me, sock):
524     me._watch = G.io_add_watch(sock, G.IO_IN,
525                                lambda *hunoz: me._conn.receive())
526   def disconnected(me):
527     G.source_remove(me._watch)
528     me._watch = None
529   def iterate(me):
530     me._mc.iteration(True)
531
532 SM.iowatch = GIOWatcher(SM)
533
534 def init():
535   """
536   Service initialization.
537
538   Add the D-Bus monitor here, because we might send commands off immediately,
539   and we want to make sure the server connection is up.
540   """
541   global DBM
542   T.Coroutine(kickpeers, name = 'kickpeers').switch()
543   DBM = DBusMonitor()
544   DBM.addmon(NetworkManagerMonitor())
545   DBM.addmon(MaemoICdMonitor())
546   G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
547                                      or True))
548
549 def parse_options():
550   """
551   Parse the command-line options.
552
553   Automatically changes directory to the requested configdir, and turns on
554   debugging.  Returns the options object.
555   """
556   op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
557                     version = '%%prog %s' % VERSION)
558
559   op.add_option('-a', '--admin-socket',
560                 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
561                 help = 'Select socket to connect to [default %default]')
562   op.add_option('-d', '--directory',
563                 metavar = 'DIR', dest = 'dir', default = T.configdir,
564                 help = 'Select current diretory [default %default]')
565   op.add_option('-c', '--config',
566                 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
567                 help = 'Select configuration [default %default]')
568   op.add_option('--daemon', dest = 'daemon',
569                 default = False, action = 'store_true',
570                 help = 'Become a daemon after successful initialization')
571   op.add_option('--debug', dest = 'debug',
572                 default = False, action = 'store_true',
573                 help = 'Emit debugging trace information')
574   op.add_option('--startup', dest = 'startup',
575                 default = False, action = 'store_true',
576                 help = 'Being called as part of the server startup')
577
578   opts, args = op.parse_args()
579   if args: op.error('no arguments permitted')
580   OS.chdir(opts.dir)
581   T._debug = opts.debug
582   return opts
583
584 ## Service table, for running manually.
585 def cmd_updown(upness):
586   return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
587 service_info = [('conntrack', VERSION, {
588   'up': (0, None, '', cmd_updown(True)),
589   'down': (0, None, '', cmd_updown(False)),
590   'show-config': (0, 0, '', cmd_showconfig),
591   'show-groups': (0, 0, '', cmd_showgroups),
592   'show-group': (1, 1, 'GROUP', cmd_showgroup)
593 })]
594
595 if __name__ == '__main__':
596   opts = parse_options()
597   CF = Config(opts.conf)
598   T.runservices(opts.tripesock, service_info,
599                 init = init, daemon = opts.daemon)
600
601 ###----- That's all, folks --------------------------------------------------