chiark / gitweb /
Merge branch 'mdw/backoff'
[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
144     ## Save the test address.  Make sure it's vaguely sensible.  The default
145     ## is probably good for most cases, in fact, since that address isn't
146     ## actually in use.  Note that we never send packets to the test address;
147     ## we just use it to discover routing information.
148     if cp.has_option('DEFAULT', 'test-addr'):
149       testaddr = cp.get('DEFAULT', 'test-addr')
150       S.inet_aton(testaddr)
151     else:
152       testaddr = '1.2.3.4'
153
154     ## Scan the configuration file and build the groups structure.
155     groups = []
156     for sec in cp.sections():
157       pats = []
158       for tag in cp.options(sec):
159         spec = cp.get(sec, tag).split()
160
161         ## Parse the entry into peer and network.
162         if len(spec) == 1:
163           peer = None
164           net = spec[0]
165         else:
166           peer, net = spec
167
168         ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
169         ## and MASK is either a dotted-quad or a single integer N indicating
170         ## a mask with N leading ones followed by trailing zeroes.
171         slash = net.index('/')
172         addr, = unpack('>L', S.inet_aton(net[:slash]))
173         if net.find('.', slash + 1) >= 0:
174           mask, = unpack('>L', S.inet_aton(net[:slash]))
175         else:
176           n = int(net[slash + 1:], 10)
177           mask = (1 << 32) - (1 << 32 - n)
178         pats.append((tag, peer, addr & mask, mask))
179
180       ## Annoyingly, RawConfigParser doesn't preserve the order of options.
181       ## In order to make things vaguely sane, we topologically sort the
182       ## patterns so that more specific patterns are checked first.
183       pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
184                              (p and not pp) or \
185                              (p == pp and m == (m | mm) and aa == (a & mm)),
186                            pats))
187       groups.append((sec, pats))
188
189     ## Done.
190     me.testaddr = testaddr
191     me.groups = groups
192
193 ### This will be a configuration file.
194 CF = None
195
196 ###--------------------------------------------------------------------------
197 ### Responding to a network up/down event.
198
199 def localaddr(peer):
200   """
201   Return the local IP address used for talking to PEER.
202   """
203   sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
204   try:
205     try:
206       sk.connect((peer, 1))
207       addr, _ = sk.getsockname()
208       addr, = unpack('>L', S.inet_aton(addr))
209       return addr
210     except S.error:
211       return None
212   finally:
213     sk.close()
214
215 _kick = T.Queue()
216 def kickpeers():
217   while True:
218     upness, reason = _kick.get()
219
220     ## Make sure the configuration file is up-to-date.  Don't worry if we
221     ## can't do anything useful.
222     try:
223       CF.check()
224     except Exception, exc:
225       SM.warn('conntrack', 'config-file-error',
226               exc.__class__.__name__, str(exc))
227
228     ## Find the current list of peers.
229     peers = SM.list()
230
231     ## Work out the primary IP address.
232     if upness:
233       addr = localaddr(CF.testaddr)
234       if addr is None:
235         upness = False
236     else:
237       addr = None
238
239     ## Now decide what to do.
240     changes = []
241     for g, pp in CF.groups:
242
243       ## Find out which peer in the group ought to be active.
244       ip = None
245       map = {}
246       want = None
247       for t, p, a, m in pp:
248         if p is None or not upness:
249           ipq = addr
250         else:
251           ipq = localaddr(p)
252         if upness and ip is None and \
253               ipq is not None and (ipq & m) == a:
254           map[t] = 'up'
255           if t == 'down' or t.startswith('down/'):
256             want = None
257           else:
258             want = t
259           ip = ipq
260         else:
261           map[t] = 'down'
262
263       ## Shut down the wrong ones.
264       found = False
265       for p in peers:
266         what = map.get(p, 'leave')
267         if what == 'up':
268           found = True
269         elif what == 'down':
270           def _(p = p):
271             try:
272               SM.kill(p)
273             except T.TripeError, exc:
274               if exc.args[0] == 'unknown-peer':
275                 ## Inherently racy; don't worry about this.
276                 pass
277               else:
278                 raise
279           changes.append(_)
280
281       ## Start the right one if necessary.
282       if want is not None and not found:
283         def _(want = want):
284           try:
285             SM.svcsubmit('connect', 'active', want)
286           except T.TripeError, exc:
287             SM.warn('conntrack', 'connect-failed', want, *exc.args)
288         changes.append(_)
289
290     ## Commit the changes.
291     if changes:
292       SM.notify('conntrack', upness and 'up' or 'down', *reason)
293       for c in changes: c()
294
295 def netupdown(upness, reason):
296   """
297   Add or kill peers according to whether the network is up or down.
298
299   UPNESS is true if the network is up, or false if it's down.
300   """
301
302   _kick.put((upness, reason))
303
304 ###--------------------------------------------------------------------------
305 ### NetworkManager monitor.
306
307 NM_NAME = 'org.freedesktop.NetworkManager'
308 NM_PATH = '/org/freedesktop/NetworkManager'
309 NM_IFACE = NM_NAME
310 NMCA_IFACE = NM_NAME + '.Connection.Active'
311
312 NM_STATE_CONNECTED = 3
313
314 class NetworkManagerMonitor (object):
315   """
316   Watch NetworkManager signals for changes in network state.
317   """
318
319   ## Strategy.  There are two kinds of interesting state transitions for us.
320   ## The first one is the global are-we-connected state, which we'll use to
321   ## toggle network upness on a global level.  The second is which connection
322   ## has the default route, which we'll use to tweak which peer in the peer
323   ## group is active.  The former is most easily tracked using the signal
324   ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
325   ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
326   ## look for when a new connection gains the default route.
327
328   def attach(me, bus):
329     try:
330       nm = bus.get_object(NM_NAME, NM_PATH)
331       state = nm.Get(NM_IFACE, 'State')
332       if state == NM_STATE_CONNECTED:
333         netupdown(True, ['nm', 'initially-connected'])
334       else:
335         netupdown(False, ['nm', 'initially-disconnected'])
336     except D.DBusException:
337       pass
338     bus.add_signal_receiver(me._nm_state, 'StateChanged', NM_IFACE,
339                             NM_NAME, NM_PATH)
340     bus.add_signal_receiver(me._nm_connchange,
341                             'PropertiesChanged', NMCA_IFACE,
342                             NM_NAME, None)
343
344   def _nm_state(me, state):
345     if state == NM_STATE_CONNECTED:
346       netupdown(True, ['nm', 'connected'])
347     else:
348       netupdown(False, ['nm', 'disconnected'])
349
350   def _nm_connchange(me, props):
351     if props.get('Default', False):
352       netupdown(True, ['nm', 'default-connection-change'])
353
354 ###--------------------------------------------------------------------------
355 ### Maemo monitor.
356
357 ICD_NAME = 'com.nokia.icd'
358 ICD_PATH = '/com/nokia/icd'
359 ICD_IFACE = ICD_NAME
360
361 class MaemoICdMonitor (object):
362   """
363   Watch ICd signals for changes in network state.
364   """
365
366   ## Strategy.  ICd only handles one connection at a time in steady state,
367   ## though when switching between connections, it tries to bring the new one
368   ## up before shutting down the old one.  This makes life a bit easier than
369   ## it is with NetworkManager.  On the other hand, the notifications are
370   ## relative to particular connections only, and the indicator that the old
371   ## connection is down (`IDLE') comes /after/ the new one comes up
372   ## (`CONNECTED'), so we have to remember which one is active.
373
374   def attach(me, bus):
375     try:
376       icd = bus.get_object(ICD_NAME, ICD_PATH)
377       try:
378         iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
379         me._iap = iap
380         netupdown(True, ['icd', 'initially-connected', iap])
381       except D.DBusException:
382         me._iap = None
383         netupdown(False, ['icd', 'initially-disconnected'])
384     except D.DBusException:
385       me._iap = None
386     bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
387                             ICD_NAME, ICD_PATH)
388
389   def _icd_state(me, iap, ty, state, hunoz):
390     if state == 'CONNECTED':
391       me._iap = iap
392       netupdown(True, ['icd', 'connected', iap])
393     elif state == 'IDLE' and iap == me._iap:
394       me._iap = None
395       netupdown(False, ['icd', 'idle'])
396
397 ###--------------------------------------------------------------------------
398 ### D-Bus connection tracking.
399
400 class DBusMonitor (object):
401   """
402   Maintains a connection to the system D-Bus, and watches for signals.
403
404   If the connection is initially down, or drops for some reason, we retry
405   periodically (every five seconds at the moment).  If the connection
406   resurfaces, we reattach the monitors.
407   """
408
409   def __init__(me):
410     """
411     Initialise the object and try to establish a connection to the bus.
412     """
413     me._mons = []
414     me._loop = D.mainloop.glib.DBusGMainLoop()
415     me._state = 'startup'
416     me._reconnect()
417
418   def addmon(me, mon):
419     """
420     Add a monitor object to watch for signals.
421
422     MON.attach(BUS) is called, with BUS being the connection to the system
423     bus.  MON should query its service's current status and watch for
424     relevant signals.
425     """
426     me._mons.append(mon)
427     if me._bus is not None:
428       mon.attach(me._bus)
429
430   def _reconnect(me, hunoz = None):
431     """
432     Start connecting to the bus.
433
434     If we fail the first time, retry periodically.
435     """
436     if me._state == 'startup':
437       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
438     elif me._state == 'connected':
439       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
440     else:
441       T.aside(SM.notify, 'conntrack', 'dbus-connection',
442               'state=%s' % me._state)
443     me._state == 'reconnecting'
444     me._bus = None
445     if me._try_connect():
446       G.timeout_add_seconds(5, me._try_connect)
447
448   def _try_connect(me):
449     """
450     Actually make a connection attempt.
451
452     If we succeed, attach the monitors.
453     """
454     try:
455       addr = OS.getenv('TRIPE_CONNTRACK_BUS')
456       if addr == 'SESSION':
457         bus = D.SessionBus(mainloop = me._loop, private = True)
458       elif addr is not None:
459         bus = D.bus.BusConnection(addr, mainloop = me._loop)
460       else:
461         bus = D.SystemBus(mainloop = me._loop, private = True)
462       for m in me._mons:
463         m.attach(bus)
464     except D.DBusException, e:
465       return True
466     me._bus = bus
467     me._state = 'connected'
468     bus.call_on_disconnection(me._reconnect)
469     T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
470     return False
471
472 ###--------------------------------------------------------------------------
473 ### TrIPE service.
474
475 class GIOWatcher (object):
476   """
477   Monitor I/O events using glib.
478   """
479   def __init__(me, conn, mc = G.main_context_default()):
480     me._conn = conn
481     me._watch = None
482     me._mc = mc
483   def connected(me, sock):
484     me._watch = G.io_add_watch(sock, G.IO_IN,
485                                lambda *hunoz: me._conn.receive())
486   def disconnected(me):
487     G.source_remove(me._watch)
488     me._watch = None
489   def iterate(me):
490     me._mc.iteration(True)
491
492 SM.iowatch = GIOWatcher(SM)
493
494 def init():
495   """
496   Service initialization.
497
498   Add the D-Bus monitor here, because we might send commands off immediately,
499   and we want to make sure the server connection is up.
500   """
501   global DBM
502   T.Coroutine(kickpeers, name = 'kickpeers').switch()
503   DBM = DBusMonitor()
504   DBM.addmon(NetworkManagerMonitor())
505   DBM.addmon(MaemoICdMonitor())
506   G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
507                                      or True))
508
509 def parse_options():
510   """
511   Parse the command-line options.
512
513   Automatically changes directory to the requested configdir, and turns on
514   debugging.  Returns the options object.
515   """
516   op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
517                     version = '%%prog %s' % VERSION)
518
519   op.add_option('-a', '--admin-socket',
520                 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
521                 help = 'Select socket to connect to [default %default]')
522   op.add_option('-d', '--directory',
523                 metavar = 'DIR', dest = 'dir', default = T.configdir,
524                 help = 'Select current diretory [default %default]')
525   op.add_option('-c', '--config',
526                 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
527                 help = 'Select configuration [default %default]')
528   op.add_option('--daemon', dest = 'daemon',
529                 default = False, action = 'store_true',
530                 help = 'Become a daemon after successful initialization')
531   op.add_option('--debug', dest = 'debug',
532                 default = False, action = 'store_true',
533                 help = 'Emit debugging trace information')
534   op.add_option('--startup', dest = 'startup',
535                 default = False, action = 'store_true',
536                 help = 'Being called as part of the server startup')
537
538   opts, args = op.parse_args()
539   if args: op.error('no arguments permitted')
540   OS.chdir(opts.dir)
541   T._debug = opts.debug
542   return opts
543
544 ## Service table, for running manually.
545 def cmd_updown(upness):
546   return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
547 service_info = [('conntrack', VERSION, {
548   'up': (0, None, '', cmd_updown(True)),
549   'down': (0, None, '', cmd_updown(False))
550 })]
551
552 if __name__ == '__main__':
553   opts = parse_options()
554   CF = Config(opts.conf)
555   T.runservices(opts.tripesock, service_info,
556                 init = init, daemon = opts.daemon)
557
558 ###----- That's all, folks --------------------------------------------------