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