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