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