chiark / gitweb /
47f66f4d4485660a861f009818eeb6e3694eeb2c
[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
237     ## Now decide what to do.
238     changes = []
239     for g, pp in CF.groups:
240
241       ## Find out which peer in the group ought to be active.
242       want = None                       # unequal to any string
243       if upness:
244         for t, p, a, m in pp:
245           if p is None:
246             aq = addr
247           else:
248             aq = localaddr(p)
249           if aq is not None and (aq & m) == a:
250             want = t
251             break
252
253       ## Shut down the wrong ones.
254       found = False
255       for p in peers:
256         if p == want:
257           found = True
258         elif p.startswith(g) and p != want:
259           changes.append(lambda p=p: SM.kill(p))
260
261       ## Start the right one if necessary.
262       if want is not None and not found:
263         changes.append(lambda: T._simple(SM.svcsubmit('connect', 'active',
264                                                       want)))
265
266     ## Commit the changes.
267     if changes:
268       SM.notify('conntrack', upness and 'up' or 'down', *reason)
269       for c in changes: c()
270
271 def netupdown(upness, reason):
272   """
273   Add or kill peers according to whether the network is up or down.
274
275   UPNESS is true if the network is up, or false if it's down.
276   """
277
278   _kick.put((upness, reason))
279
280 ###--------------------------------------------------------------------------
281 ### NetworkManager monitor.
282
283 NM_NAME = 'org.freedesktop.NetworkManager'
284 NM_PATH = '/org/freedesktop/NetworkManager'
285 NM_IFACE = NM_NAME
286 NMCA_IFACE = NM_NAME + '.Connection.Active'
287
288 NM_STATE_CONNECTED = 3
289
290 class NetworkManagerMonitor (object):
291   """
292   Watch NetworkManager signals for changes in network state.
293   """
294
295   ## Strategy.  There are two kinds of interesting state transitions for us.
296   ## The first one is the global are-we-connected state, which we'll use to
297   ## toggle network upness on a global level.  The second is which connection
298   ## has the default route, which we'll use to tweak which peer in the peer
299   ## group is active.  The former is most easily tracked using the signal
300   ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
301   ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
302   ## look for when a new connection gains the default route.
303
304   def attach(me, bus):
305     try:
306       nm = bus.get_object(NM_NAME, NM_PATH)
307       state = nm.Get(NM_IFACE, 'State')
308       if state == NM_STATE_CONNECTED:
309         netupdown(True, ['nm', 'initially-connected'])
310       else:
311         netupdown(False, ['nm', 'initially-disconnected'])
312     except D.DBusException:
313       pass
314     bus.add_signal_receiver(me._nm_state, 'StateChanged', NM_IFACE,
315                             NM_NAME, NM_PATH)
316     bus.add_signal_receiver(me._nm_connchange,
317                             'PropertiesChanged', NMCA_IFACE,
318                             NM_NAME, None)
319
320   def _nm_state(me, state):
321     if state == NM_STATE_CONNECTED:
322       netupdown(True, ['nm', 'connected'])
323     else:
324       netupdown(False, ['nm', 'disconnected'])
325
326   def _nm_connchange(me, props):
327     if props.get('Default', False):
328       netupdown(True, ['nm', 'default-connection-change'])
329
330 ###--------------------------------------------------------------------------
331 ### Maemo monitor.
332
333 ICD_NAME = 'com.nokia.icd'
334 ICD_PATH = '/com/nokia/icd'
335 ICD_IFACE = ICD_NAME
336
337 class MaemoICdMonitor (object):
338   """
339   Watch ICd signals for changes in network state.
340   """
341
342   ## Strategy.  ICd only handles one connection at a time in steady state,
343   ## though when switching between connections, it tries to bring the new one
344   ## up before shutting down the old one.  This makes life a bit easier than
345   ## it is with NetworkManager.  On the other hand, the notifications are
346   ## relative to particular connections only, and the indicator that the old
347   ## connection is down (`IDLE') comes /after/ the new one comes up
348   ## (`CONNECTED'), so we have to remember which one is active.
349
350   def attach(me, bus):
351     try:
352       icd = bus.get_object(ICD_NAME, ICD_PATH)
353       try:
354         iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
355         me._iap = iap
356         netupdown(True, ['icd', 'initially-connected', iap])
357       except D.DBusException:
358         me._iap = None
359         netupdown(False, ['icd', 'initially-disconnected'])
360     except D.DBusException:
361       me._iap = None
362     bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
363                             ICD_NAME, ICD_PATH)
364
365   def _icd_state(me, iap, ty, state, hunoz):
366     if state == 'CONNECTED':
367       me._iap = iap
368       netupdown(True, ['icd', 'connected', iap])
369     elif state == 'IDLE' and iap == me._iap:
370       me._iap = None
371       netupdown(False, ['icd', 'idle'])
372
373 ###--------------------------------------------------------------------------
374 ### D-Bus connection tracking.
375
376 class DBusMonitor (object):
377   """
378   Maintains a connection to the system D-Bus, and watches for signals.
379
380   If the connection is initially down, or drops for some reason, we retry
381   periodically (every five seconds at the moment).  If the connection
382   resurfaces, we reattach the monitors.
383   """
384
385   def __init__(me):
386     """
387     Initialise the object and try to establish a connection to the bus.
388     """
389     me._mons = []
390     me._loop = D.mainloop.glib.DBusGMainLoop()
391     me._state = 'startup'
392     me._reconnect()
393
394   def addmon(me, mon):
395     """
396     Add a monitor object to watch for signals.
397
398     MON.attach(BUS) is called, with BUS being the connection to the system
399     bus.  MON should query its service's current status and watch for
400     relevant signals.
401     """
402     me._mons.append(mon)
403     if me._bus is not None:
404       mon.attach(me._bus)
405
406   def _reconnect(me, hunoz = None):
407     """
408     Start connecting to the bus.
409
410     If we fail the first time, retry periodically.
411     """
412     if me._state == 'startup':
413       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
414     elif me._state == 'connected':
415       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
416     else:
417       T.aside(SM.notify, 'conntrack', 'dbus-connection',
418               'state=%s' % me._state)
419     me._state == 'reconnecting'
420     me._bus = None
421     if me._try_connect():
422       G.timeout_add_seconds(5, me._try_connect)
423
424   def _try_connect(me):
425     """
426     Actually make a connection attempt.
427
428     If we succeed, attach the monitors.
429     """
430     try:
431       addr = OS.getenv('TRIPE_CONNTRACK_BUS')
432       if addr == 'SESSION':
433         bus = D.SessionBus(mainloop = me._loop, private = True)
434       elif addr is not None:
435         bus = D.bus.BusConnection(addr, mainloop = me._loop)
436       else:
437         bus = D.SystemBus(mainloop = me._loop, private = True)
438       for m in me._mons:
439         m.attach(bus)
440     except D.DBusException, e:
441       return True
442     me._bus = bus
443     me._state = 'connected'
444     bus.call_on_disconnection(me._reconnect)
445     T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
446     return False
447
448 ###--------------------------------------------------------------------------
449 ### TrIPE service.
450
451 class GIOWatcher (object):
452   """
453   Monitor I/O events using glib.
454   """
455   def __init__(me, conn, mc = G.main_context_default()):
456     me._conn = conn
457     me._watch = None
458     me._mc = mc
459   def connected(me, sock):
460     me._watch = G.io_add_watch(sock, G.IO_IN,
461                                lambda *hunoz: me._conn.receive())
462   def disconnected(me):
463     G.source_remove(me._watch)
464     me._watch = None
465   def iterate(me):
466     me._mc.iteration(True)
467
468 SM.iowatch = GIOWatcher(SM)
469
470 def init():
471   """
472   Service initialization.
473
474   Add the D-Bus monitor here, because we might send commands off immediately,
475   and we want to make sure the server connection is up.
476   """
477   global DBM
478   T.Coroutine(kickpeers, name = 'kickpeers').switch()
479   DBM = DBusMonitor()
480   DBM.addmon(NetworkManagerMonitor())
481   DBM.addmon(MaemoICdMonitor())
482   G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
483                                      or True))
484
485 def parse_options():
486   """
487   Parse the command-line options.
488
489   Automatically changes directory to the requested configdir, and turns on
490   debugging.  Returns the options object.
491   """
492   op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
493                     version = '%%prog %s' % VERSION)
494
495   op.add_option('-a', '--admin-socket',
496                 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
497                 help = 'Select socket to connect to [default %default]')
498   op.add_option('-d', '--directory',
499                 metavar = 'DIR', dest = 'dir', default = T.configdir,
500                 help = 'Select current diretory [default %default]')
501   op.add_option('-c', '--config',
502                 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
503                 help = 'Select configuration [default %default]')
504   op.add_option('--daemon', dest = 'daemon',
505                 default = False, action = 'store_true',
506                 help = 'Become a daemon after successful initialization')
507   op.add_option('--debug', dest = 'debug',
508                 default = False, action = 'store_true',
509                 help = 'Emit debugging trace information')
510   op.add_option('--startup', dest = 'startup',
511                 default = False, action = 'store_true',
512                 help = 'Being called as part of the server startup')
513
514   opts, args = op.parse_args()
515   if args: op.error('no arguments permitted')
516   OS.chdir(opts.dir)
517   T._debug = opts.debug
518   return opts
519
520 ## Service table, for running manually.
521 def cmd_updown(upness):
522   return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
523 service_info = [('conntrack', VERSION, {
524   'up': (0, None, '', cmd_updown(True)),
525   'down': (0, None, '', cmd_updown(False))
526 })]
527
528 if __name__ == '__main__':
529   opts = parse_options()
530   CF = Config(opts.conf)
531   T.runservices(opts.tripesock, service_info,
532                 init = init, daemon = opts.daemon)
533
534 ###----- That's all, folks --------------------------------------------------