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