chiark / gitweb /
svc/conntrack.in (strmask): Consistently return a string object.
[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[slash + 1:].isdigit():
175           n = int(net[slash + 1:], 10)
176           mask = (1 << 32) - (1 << 32 - n)
177         else:
178           mask, = unpack('>L', S.inet_aton(net[slash + 1:]))
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 str(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 _delay = None
241
242 def cancel_delay():
243   global _delay
244   if _delay is not None:
245     if T._debug: print '# cancel delayed kick'
246     G.source_remove(_delay)
247     _delay = None
248
249 def netupdown(upness, reason):
250   """
251   Add or kill peers according to whether the network is up or down.
252
253   UPNESS is true if the network is up, or false if it's down.
254   """
255
256   _kick.put((upness, reason))
257
258 def delay_netupdown(upness, reason):
259   global _delay
260   cancel_delay()
261   def _func():
262     global _delay
263     if T._debug: print '# delayed %s: %s' % (upness, reason)
264     _delay = None
265     netupdown(upness, reason)
266     return False
267   if T._debug: print '# delaying %s: %s' % (upness, reason)
268   _delay = G.timeout_add(2000, _func)
269
270 def kickpeers():
271   while True:
272     upness, reason = _kick.get()
273     if T._debug: print '# kickpeers %s: %s' % (upness, reason)
274     select = []
275     cancel_delay()
276
277     ## Make sure the configuration file is up-to-date.  Don't worry if we
278     ## can't do anything useful.
279     try:
280       CF.check()
281     except Exception, exc:
282       SM.warn('conntrack', 'config-file-error',
283               exc.__class__.__name__, str(exc))
284
285     ## Find the current list of peers.
286     peers = SM.list()
287
288     ## Work out the primary IP address.
289     if upness:
290       addr = localaddr(CF.testaddr)
291       if addr is None:
292         upness = False
293     else:
294       addr = None
295     if not T._debug: pass
296     elif addr: print '#   local address = %s' % straddr(addr)
297     else: print '#   offline'
298
299     ## Now decide what to do.
300     changes = []
301     for g, pp in CF.groups:
302       if T._debug: print '#   check group %s' % g
303
304       ## Find out which peer in the group ought to be active.
305       ip = None
306       map = {}
307       want = None
308       for t, p, a, m in pp:
309         if p is None or not upness:
310           ipq = addr
311         else:
312           ipq = localaddr(p)
313         if T._debug:
314           info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
315             t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
316         if upness and ip is None and \
317               ipq is not None and (ipq & m) == a:
318           if T._debug: print '#     %s: SELECTED' % info
319           map[t] = 'up'
320           select.append('%s=%s' % (g, t))
321           if t == 'down' or t.startswith('down/'):
322             want = None
323           else:
324             want = t
325           ip = ipq
326         else:
327           map[t] = 'down'
328           if T._debug: print '#     %s: skipped' % info
329
330       ## Shut down the wrong ones.
331       found = False
332       if T._debug: print '#   peer-map = %r' % map
333       for p in peers:
334         what = map.get(p, 'leave')
335         if what == 'up':
336           found = True
337           if T._debug: print '#   peer %s: already up' % p
338         elif what == 'down':
339           def _(p = p):
340             try:
341               SM.kill(p)
342             except T.TripeError, exc:
343               if exc.args[0] == 'unknown-peer':
344                 ## Inherently racy; don't worry about this.
345                 pass
346               else:
347                 raise
348           if T._debug: print '#   peer %s: bring down' % p
349           changes.append(_)
350
351       ## Start the right one if necessary.
352       if want is not None and not found:
353         def _(want = want):
354           try:
355             list(SM.svcsubmit('connect', 'active', want))
356           except T.TripeError, exc:
357             SM.warn('conntrack', 'connect-failed', want, *exc.args)
358         if T._debug: print '#   peer %s: bring up' % want
359         changes.append(_)
360
361     ## Commit the changes.
362     if changes:
363       SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
364       for c in changes: c()
365
366 ###--------------------------------------------------------------------------
367 ### NetworkManager monitor.
368
369 DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
370
371 NM_NAME = 'org.freedesktop.NetworkManager'
372 NM_PATH = '/org/freedesktop/NetworkManager'
373 NM_IFACE = NM_NAME
374 NMCA_IFACE = NM_NAME + '.Connection.Active'
375
376 NM_STATE_CONNECTED = 3 #obsolete
377 NM_STATE_CONNECTED_LOCAL = 50
378 NM_STATE_CONNECTED_SITE = 60
379 NM_STATE_CONNECTED_GLOBAL = 70
380 NM_CONNSTATES = set([NM_STATE_CONNECTED,
381                      NM_STATE_CONNECTED_LOCAL,
382                      NM_STATE_CONNECTED_SITE,
383                      NM_STATE_CONNECTED_GLOBAL])
384
385 class NetworkManagerMonitor (object):
386   """
387   Watch NetworkManager signals for changes in network state.
388   """
389
390   ## Strategy.  There are two kinds of interesting state transitions for us.
391   ## The first one is the global are-we-connected state, which we'll use to
392   ## toggle network upness on a global level.  The second is which connection
393   ## has the default route, which we'll use to tweak which peer in the peer
394   ## group is active.  The former is most easily tracked using the signal
395   ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
396   ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
397   ## look for when a new connection gains the default route.
398
399   def attach(me, bus):
400     try:
401       nm = bus.get_object(NM_NAME, NM_PATH)
402       state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
403       if state in NM_CONNSTATES:
404         netupdown(True, ['nm', 'initially-connected'])
405       else:
406         netupdown(False, ['nm', 'initially-disconnected'])
407     except D.DBusException, e:
408       if T._debug: print '# exception attaching to network-manager: %s' % e
409     bus.add_signal_receiver(me._nm_state, 'StateChanged',
410                             NM_IFACE, NM_NAME, NM_PATH)
411     bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
412                             NMCA_IFACE, NM_NAME, None)
413
414   def _nm_state(me, state):
415     if state in NM_CONNSTATES:
416       delay_netupdown(True, ['nm', 'connected'])
417     else:
418       delay_netupdown(False, ['nm', 'disconnected'])
419
420   def _nm_connchange(me, props):
421     if props.get('Default', False) or props.get('Default6', False):
422       delay_netupdown(True, ['nm', 'default-connection-change'])
423
424 ##--------------------------------------------------------------------------
425 ### Connman monitor.
426
427 CM_NAME = 'net.connman'
428 CM_PATH = '/'
429 CM_IFACE = 'net.connman.Manager'
430
431 class ConnManMonitor (object):
432   """
433   Watch ConnMan signls for changes in network state.
434   """
435
436   ## Strategy.  Everything seems to be usefully encoded in the `State'
437   ## property.  If it's `offline', `idle' or `ready' then we don't expect a
438   ## network connection.  During handover from one network to another, the
439   ## property passes through `ready' to `online'.
440
441   def attach(me, bus):
442     try:
443       cm = bus.get_object(CM_NAME, CM_PATH)
444       props = cm.GetProperties(dbus_interface = CM_IFACE)
445       state = props['State']
446       netupdown(state == 'online', ['connman', 'initially-%s' % state])
447     except D.DBusException, e:
448       if T._debug: print '# exception attaching to connman: %s' % e
449     bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
450                             CM_IFACE, CM_NAME, CM_PATH)
451
452   def _cm_state(me, prop, value):
453     if prop != 'State': return
454     delay_netupdown(value == 'online', ['connman', value])
455
456 ###--------------------------------------------------------------------------
457 ### Maemo monitor.
458
459 ICD_NAME = 'com.nokia.icd'
460 ICD_PATH = '/com/nokia/icd'
461 ICD_IFACE = ICD_NAME
462
463 class MaemoICdMonitor (object):
464   """
465   Watch ICd signals for changes in network state.
466   """
467
468   ## Strategy.  ICd only handles one connection at a time in steady state,
469   ## though when switching between connections, it tries to bring the new one
470   ## up before shutting down the old one.  This makes life a bit easier than
471   ## it is with NetworkManager.  On the other hand, the notifications are
472   ## relative to particular connections only, and the indicator that the old
473   ## connection is down (`IDLE') comes /after/ the new one comes up
474   ## (`CONNECTED'), so we have to remember which one is active.
475
476   def attach(me, bus):
477     try:
478       icd = bus.get_object(ICD_NAME, ICD_PATH)
479       try:
480         iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
481         me._iap = iap
482         netupdown(True, ['icd', 'initially-connected', iap])
483       except D.DBusException:
484         me._iap = None
485         netupdown(False, ['icd', 'initially-disconnected'])
486     except D.DBusException, e:
487       if T._debug: print '# exception attaching to icd: %s' % e
488       me._iap = None
489     bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
490                             ICD_NAME, ICD_PATH)
491
492   def _icd_state(me, iap, ty, state, hunoz):
493     if state == 'CONNECTED':
494       me._iap = iap
495       delay_netupdown(True, ['icd', 'connected', iap])
496     elif state == 'IDLE' and iap == me._iap:
497       me._iap = None
498       delay_netupdown(False, ['icd', 'idle'])
499
500 ###--------------------------------------------------------------------------
501 ### D-Bus connection tracking.
502
503 class DBusMonitor (object):
504   """
505   Maintains a connection to the system D-Bus, and watches for signals.
506
507   If the connection is initially down, or drops for some reason, we retry
508   periodically (every five seconds at the moment).  If the connection
509   resurfaces, we reattach the monitors.
510   """
511
512   def __init__(me):
513     """
514     Initialise the object and try to establish a connection to the bus.
515     """
516     me._mons = []
517     me._loop = D.mainloop.glib.DBusGMainLoop()
518     me._state = 'startup'
519     me._reconnect()
520
521   def addmon(me, mon):
522     """
523     Add a monitor object to watch for signals.
524
525     MON.attach(BUS) is called, with BUS being the connection to the system
526     bus.  MON should query its service's current status and watch for
527     relevant signals.
528     """
529     me._mons.append(mon)
530     if me._bus is not None:
531       mon.attach(me._bus)
532
533   def _reconnect(me, hunoz = None):
534     """
535     Start connecting to the bus.
536
537     If we fail the first time, retry periodically.
538     """
539     if me._state == 'startup':
540       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
541     elif me._state == 'connected':
542       T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
543     else:
544       T.aside(SM.notify, 'conntrack', 'dbus-connection',
545               'state=%s' % me._state)
546     me._state == 'reconnecting'
547     me._bus = None
548     if me._try_connect():
549       G.timeout_add_seconds(5, me._try_connect)
550
551   def _try_connect(me):
552     """
553     Actually make a connection attempt.
554
555     If we succeed, attach the monitors.
556     """
557     try:
558       addr = OS.getenv('TRIPE_CONNTRACK_BUS')
559       if addr == 'SESSION':
560         bus = D.SessionBus(mainloop = me._loop, private = True)
561       elif addr is not None:
562         bus = D.bus.BusConnection(addr, mainloop = me._loop)
563       else:
564         bus = D.SystemBus(mainloop = me._loop, private = True)
565       for m in me._mons:
566         m.attach(bus)
567     except D.DBusException, e:
568       return True
569     me._bus = bus
570     me._state = 'connected'
571     bus.call_on_disconnection(me._reconnect)
572     T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
573     return False
574
575 ###--------------------------------------------------------------------------
576 ### TrIPE service.
577
578 class GIOWatcher (object):
579   """
580   Monitor I/O events using glib.
581   """
582   def __init__(me, conn, mc = G.main_context_default()):
583     me._conn = conn
584     me._watch = None
585     me._mc = mc
586   def connected(me, sock):
587     me._watch = G.io_add_watch(sock, G.IO_IN,
588                                lambda *hunoz: me._conn.receive())
589   def disconnected(me):
590     G.source_remove(me._watch)
591     me._watch = None
592   def iterate(me):
593     me._mc.iteration(True)
594
595 SM.iowatch = GIOWatcher(SM)
596
597 def init():
598   """
599   Service initialization.
600
601   Add the D-Bus monitor here, because we might send commands off immediately,
602   and we want to make sure the server connection is up.
603   """
604   global DBM
605   T.Coroutine(kickpeers, name = 'kickpeers').switch()
606   DBM = DBusMonitor()
607   DBM.addmon(NetworkManagerMonitor())
608   DBM.addmon(ConnManMonitor())
609   DBM.addmon(MaemoICdMonitor())
610   G.timeout_add_seconds(30, lambda: (_delay is not None or
611                                      netupdown(True, ['interval-timer']) or
612                                      True))
613
614 def parse_options():
615   """
616   Parse the command-line options.
617
618   Automatically changes directory to the requested configdir, and turns on
619   debugging.  Returns the options object.
620   """
621   op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
622                     version = '%%prog %s' % VERSION)
623
624   op.add_option('-a', '--admin-socket',
625                 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
626                 help = 'Select socket to connect to [default %default]')
627   op.add_option('-d', '--directory',
628                 metavar = 'DIR', dest = 'dir', default = T.configdir,
629                 help = 'Select current diretory [default %default]')
630   op.add_option('-c', '--config',
631                 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
632                 help = 'Select configuration [default %default]')
633   op.add_option('--daemon', dest = 'daemon',
634                 default = False, action = 'store_true',
635                 help = 'Become a daemon after successful initialization')
636   op.add_option('--debug', dest = 'debug',
637                 default = False, action = 'store_true',
638                 help = 'Emit debugging trace information')
639   op.add_option('--startup', dest = 'startup',
640                 default = False, action = 'store_true',
641                 help = 'Being called as part of the server startup')
642
643   opts, args = op.parse_args()
644   if args: op.error('no arguments permitted')
645   OS.chdir(opts.dir)
646   T._debug = opts.debug
647   return opts
648
649 ## Service table, for running manually.
650 def cmd_updown(upness):
651   return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
652 service_info = [('conntrack', VERSION, {
653   'up': (0, None, '', cmd_updown(True)),
654   'down': (0, None, '', cmd_updown(False)),
655   'show-config': (0, 0, '', cmd_showconfig),
656   'show-groups': (0, 0, '', cmd_showgroups),
657   'show-group': (1, 1, 'GROUP', cmd_showgroup)
658 })]
659
660 if __name__ == '__main__':
661   opts = parse_options()
662   CF = Config(opts.conf)
663   T.runservices(opts.tripesock, service_info,
664                 init = init, daemon = opts.daemon)
665
666 ###----- That's all, folks --------------------------------------------------