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