chiark / gitweb /
svc/conntrack.in: Monitor class for ConnMan.
[tripe] / svc / conntrack.in
CommitLineData
2ec90437
MW
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
14### it under the terms of the GNU General Public License as published by
15### the Free Software Foundation; either version 2 of the License, or
16### (at your option) any later version.
17###
18### TrIPE is distributed in the hope that it will be useful,
19### but WITHOUT ANY WARRANTY; without even the implied warranty of
20### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21### GNU General Public License for more details.
22###
23### You should have received a copy of the GNU General Public License
24### along with TrIPE; if not, write to the Free Software Foundation,
25### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
26
27VERSION = '@VERSION@'
28
29###--------------------------------------------------------------------------
30### External dependencies.
31
32from ConfigParser import RawConfigParser
33from optparse import OptionParser
34import os as OS
35import sys as SYS
36import socket as S
37import mLib as M
38import tripe as T
39import dbus as D
40for i in ['mainloop', 'mainloop.glib']:
41 __import__('dbus.%s' % i)
a69f4417
MW
42try: from gi.repository import GLib as G
43except ImportError: import gobject as G
2ec90437
MW
44from struct import pack, unpack
45
46SM = T.svcmgr
47##__import__('rmcr').__debug = True
48
49###--------------------------------------------------------------------------
50### Utilities.
51
52class struct (object):
53 """A simple container object."""
54 def __init__(me, **kw):
55 me.__dict__.update(kw)
56
57def toposort(cmp, things):
58 """
59 Generate the THINGS in an order consistent with a given partial order.
60
61 The function CMP(X, Y) should return true if X must precede Y, and false if
62 it doesn't care. If X and Y are equal then it should return false.
63
64 The THINGS may be any finite iterable; it is converted to a list
65 internally.
66 """
67
68 ## Make sure we can index the THINGS, and prepare an ordering table.
69 ## What's going on? The THINGS might not have a helpful equality
70 ## predicate, so it's easier to work with indices. The ordering table will
71 ## remember which THINGS (by index) are considered greater than other
72 ## things.
73 things = list(things)
74 n = len(things)
75 order = [{} for i in xrange(n)]
76 rorder = [{} for i in xrange(n)]
77 for i in xrange(n):
78 for j in xrange(n):
79 if i != j and cmp(things[i], things[j]):
80 order[j][i] = True
81 rorder[i][j] = True
82
83 ## Now we can do the sort.
84 out = []
85 while True:
86 done = True
87 for i in xrange(n):
88 if order[i] is not None:
89 done = False
90 if len(order[i]) == 0:
91 for j in rorder[i]:
92 del order[j][i]
93 yield things[i]
94 order[i] = None
95 if done:
96 break
97
98###--------------------------------------------------------------------------
99### Parse the configuration file.
100
101## Hmm. Should I try to integrate this with the peers database? It's not a
102## good fit; it'd need special hacks in tripe-newpeers. And the use case for
103## this service are largely going to be satellite notes, I don't think
104## scalability's going to be a problem.
105
106class Config (object):
107 """
108 Represents a configuration file.
109
110 The most interesting thing is probably the `groups' slot, which stores a
111 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
112 list of (TAG, PEER, ADDR, MASK) triples. The implication is that there
113 should be precisely one peer with a name matching NAME-*, and that it
114 should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such
115 that the host's primary IP address (if PEER is None -- or the IP address it
116 would use for communicating with PEER) is within the network defined by
117 ADDR/MASK.
118 """
119
120 def __init__(me, file):
121 """
122 Construct a new Config object, reading the given FILE.
123 """
124 me._file = file
125 me._fwatch = M.FWatch(file)
126 me._update()
127
128 def check(me):
129 """
130 See whether the configuration file has been updated.
131 """
132 if me._fwatch.update():
133 me._update()
134
135 def _update(me):
136 """
137 Internal function to update the configuration from the underlying file.
138 """
139
140 ## Read the configuration. We have no need of the fancy substitutions,
141 ## so turn them all off.
142 cp = RawConfigParser()
143 cp.read(me._file)
2d4998c4 144 if T._debug: print '# reread config'
2ec90437
MW
145
146 ## Save the test address. Make sure it's vaguely sensible. The default
147 ## is probably good for most cases, in fact, since that address isn't
148 ## actually in use. Note that we never send packets to the test address;
149 ## we just use it to discover routing information.
150 if cp.has_option('DEFAULT', 'test-addr'):
151 testaddr = cp.get('DEFAULT', 'test-addr')
152 S.inet_aton(testaddr)
153 else:
154 testaddr = '1.2.3.4'
155
156 ## Scan the configuration file and build the groups structure.
157 groups = []
158 for sec in cp.sections():
159 pats = []
160 for tag in cp.options(sec):
161 spec = cp.get(sec, tag).split()
162
163 ## Parse the entry into peer and network.
164 if len(spec) == 1:
165 peer = None
166 net = spec[0]
167 else:
168 peer, net = spec
169
170 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
171 ## and MASK is either a dotted-quad or a single integer N indicating
172 ## a mask with N leading ones followed by trailing zeroes.
173 slash = net.index('/')
174 addr, = unpack('>L', S.inet_aton(net[:slash]))
175 if net.find('.', slash + 1) >= 0:
176 mask, = unpack('>L', S.inet_aton(net[:slash]))
177 else:
178 n = int(net[slash + 1:], 10)
179 mask = (1 << 32) - (1 << 32 - n)
180 pats.append((tag, peer, addr & mask, mask))
181
182 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
183 ## In order to make things vaguely sane, we topologically sort the
184 ## patterns so that more specific patterns are checked first.
185 pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
186 (p and not pp) or \
187 (p == pp and m == (m | mm) and aa == (a & mm)),
188 pats))
189 groups.append((sec, pats))
190
191 ## Done.
192 me.testaddr = testaddr
193 me.groups = groups
194
195### This will be a configuration file.
196CF = None
197
c3897a7d 198def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
2d4998c4
MW
199def strmask(m):
200 for i in xrange(33):
201 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return i
202 return straddr(m)
203
204def cmd_showconfig():
205 T.svcinfo('test-addr=%s' % CF.testaddr)
206def cmd_showgroups():
207 for sec, pats in CF.groups:
208 T.svcinfo(sec)
209def cmd_showgroup(g):
210 for s, p in CF.groups:
211 if s == g:
212 pats = p
213 break
214 else:
215 raise T.TripeJobError, 'unknown-group', g
216 for t, p, a, m in pats:
217 T.svcinfo('peer', t,
218 'target', p or '(default)',
219 'net', '%s/%s' % (straddr(a), strmask(m)))
220
2ec90437
MW
221###--------------------------------------------------------------------------
222### Responding to a network up/down event.
223
224def localaddr(peer):
225 """
226 Return the local IP address used for talking to PEER.
227 """
228 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
229 try:
230 try:
231 sk.connect((peer, 1))
232 addr, _ = sk.getsockname()
233 addr, = unpack('>L', S.inet_aton(addr))
234 return addr
235 except S.error:
236 return None
237 finally:
238 sk.close()
239
240_kick = T.Queue()
241def kickpeers():
242 while True:
243 upness, reason = _kick.get()
2d4998c4
MW
244 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
245 select = []
2ec90437
MW
246
247 ## Make sure the configuration file is up-to-date. Don't worry if we
248 ## can't do anything useful.
249 try:
250 CF.check()
251 except Exception, exc:
252 SM.warn('conntrack', 'config-file-error',
253 exc.__class__.__name__, str(exc))
254
255 ## Find the current list of peers.
256 peers = SM.list()
257
258 ## Work out the primary IP address.
259 if upness:
260 addr = localaddr(CF.testaddr)
261 if addr is None:
262 upness = False
b10a8c3d
MW
263 else:
264 addr = None
2d4998c4
MW
265 if not T._debug: pass
266 elif addr: print '# local address = %s' % straddr(addr)
267 else: print '# offline'
2ec90437
MW
268
269 ## Now decide what to do.
270 changes = []
271 for g, pp in CF.groups:
2d4998c4 272 if T._debug: print '# check group %s' % g
2ec90437
MW
273
274 ## Find out which peer in the group ought to be active.
b10a8c3d
MW
275 ip = None
276 map = {}
277 want = None
278 for t, p, a, m in pp:
279 if p is None or not upness:
280 ipq = addr
281 else:
282 ipq = localaddr(p)
2d4998c4
MW
283 if T._debug:
284 info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
285 t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
b10a8c3d
MW
286 if upness and ip is None and \
287 ipq is not None and (ipq & m) == a:
2d4998c4 288 if T._debug: print '# %s: SELECTED' % info
b10a8c3d 289 map[t] = 'up'
2d4998c4 290 select.append('%s=%s' % (g, t))
f2bdb96e
MW
291 if t == 'down' or t.startswith('down/'):
292 want = None
293 else:
294 want = t
b10a8c3d
MW
295 ip = ipq
296 else:
297 map[t] = 'down'
2d4998c4 298 if T._debug: print '# %s: skipped' % info
2ec90437
MW
299
300 ## Shut down the wrong ones.
301 found = False
2d4998c4 302 if T._debug: print '# peer-map = %r' % map
2ec90437 303 for p in peers:
b10a8c3d
MW
304 what = map.get(p, 'leave')
305 if what == 'up':
2ec90437 306 found = True
2d4998c4 307 if T._debug: print '# peer %s: already up' % p
b10a8c3d 308 elif what == 'down':
cf2e4ea6
MW
309 def _(p = p):
310 try:
311 SM.kill(p)
312 except T.TripeError, exc:
313 if exc.args[0] == 'unknown-peer':
314 ## Inherently racy; don't worry about this.
315 pass
316 else:
317 raise
2d4998c4 318 if T._debug: print '# peer %s: bring down' % p
cf2e4ea6 319 changes.append(_)
2ec90437
MW
320
321 ## Start the right one if necessary.
7b7e3c74 322 if want is not None and not found:
cf2e4ea6
MW
323 def _(want = want):
324 try:
325 SM.svcsubmit('connect', 'active', want)
326 except T.TripeError, exc:
327 SM.warn('conntrack', 'connect-failed', want, *exc.args)
2d4998c4 328 if T._debug: print '# peer %s: bring up' % want
cf2e4ea6 329 changes.append(_)
2ec90437
MW
330
331 ## Commit the changes.
332 if changes:
2d4998c4 333 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
2ec90437
MW
334 for c in changes: c()
335
336def netupdown(upness, reason):
337 """
338 Add or kill peers according to whether the network is up or down.
339
340 UPNESS is true if the network is up, or false if it's down.
341 """
342
343 _kick.put((upness, reason))
344
345###--------------------------------------------------------------------------
346### NetworkManager monitor.
347
348NM_NAME = 'org.freedesktop.NetworkManager'
349NM_PATH = '/org/freedesktop/NetworkManager'
350NM_IFACE = NM_NAME
351NMCA_IFACE = NM_NAME + '.Connection.Active'
352
2079efa1
MW
353NM_STATE_CONNECTED = 3 #obsolete
354NM_STATE_CONNECTED_LOCAL = 50
355NM_STATE_CONNECTED_SITE = 60
356NM_STATE_CONNECTED_GLOBAL = 70
357NM_CONNSTATES = set([NM_STATE_CONNECTED,
358 NM_STATE_CONNECTED_LOCAL,
359 NM_STATE_CONNECTED_SITE,
360 NM_STATE_CONNECTED_GLOBAL])
2ec90437
MW
361
362class NetworkManagerMonitor (object):
363 """
364 Watch NetworkManager signals for changes in network state.
365 """
366
367 ## Strategy. There are two kinds of interesting state transitions for us.
368 ## The first one is the global are-we-connected state, which we'll use to
369 ## toggle network upness on a global level. The second is which connection
370 ## has the default route, which we'll use to tweak which peer in the peer
371 ## group is active. The former is most easily tracked using the signal
372 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
373 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
374 ## look for when a new connection gains the default route.
375
376 def attach(me, bus):
377 try:
378 nm = bus.get_object(NM_NAME, NM_PATH)
379 state = nm.Get(NM_IFACE, 'State')
2079efa1 380 if state in NM_CONNSTATES:
2ec90437
MW
381 netupdown(True, ['nm', 'initially-connected'])
382 else:
383 netupdown(False, ['nm', 'initially-disconnected'])
384 except D.DBusException:
385 pass
2079efa1
MW
386 bus.add_signal_receiver(me._nm_state, 'StateChanged',
387 NM_IFACE, NM_NAME, NM_PATH)
388 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
389 NMCA_IFACE, NM_NAME, None)
2ec90437
MW
390
391 def _nm_state(me, state):
2079efa1 392 if state in NM_CONNSTATES:
2ec90437
MW
393 netupdown(True, ['nm', 'connected'])
394 else:
395 netupdown(False, ['nm', 'disconnected'])
396
397 def _nm_connchange(me, props):
398 if props.get('Default', False):
399 netupdown(True, ['nm', 'default-connection-change'])
400
a95eb44a
MW
401##--------------------------------------------------------------------------
402### Connman monitor.
403
404CM_NAME = 'net.connman'
405CM_PATH = '/'
406CM_IFACE = 'net.connman.Manager'
407
408class ConnManMonitor (object):
409 """
410 Watch ConnMan signls for changes in network state.
411 """
412
413 ## Strategy. Everything seems to be usefully encoded in the `State'
414 ## property. If it's `offline', `idle' or `ready' then we don't expect a
415 ## network connection. During handover from one network to another, the
416 ## property passes through `ready' to `online'.
417
418 def attach(me, bus):
419 try:
420 cm = bus.get_object(CM_NAME, CM_PATH)
421 props = cm.GetProperties(dbus_interface = CM_IFACE)
422 state = props['State']
423 netupdown(state == 'online', ['connman', 'initially-%s' % state])
424 except D.DBusException:
425 pass
426 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
427 CM_IFACE, CM_NAME, CM_PATH)
428
429 def _cm_state(me, prop, value):
430 if prop != 'State': return
431 netupdown(value == 'online', ['connman', value])
432
2ec90437
MW
433###--------------------------------------------------------------------------
434### Maemo monitor.
435
436ICD_NAME = 'com.nokia.icd'
437ICD_PATH = '/com/nokia/icd'
438ICD_IFACE = ICD_NAME
439
440class MaemoICdMonitor (object):
441 """
442 Watch ICd signals for changes in network state.
443 """
444
445 ## Strategy. ICd only handles one connection at a time in steady state,
446 ## though when switching between connections, it tries to bring the new one
447 ## up before shutting down the old one. This makes life a bit easier than
448 ## it is with NetworkManager. On the other hand, the notifications are
449 ## relative to particular connections only, and the indicator that the old
450 ## connection is down (`IDLE') comes /after/ the new one comes up
451 ## (`CONNECTED'), so we have to remember which one is active.
452
453 def attach(me, bus):
454 try:
455 icd = bus.get_object(ICD_NAME, ICD_PATH)
456 try:
457 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
458 me._iap = iap
459 netupdown(True, ['icd', 'initially-connected', iap])
460 except D.DBusException:
461 me._iap = None
462 netupdown(False, ['icd', 'initially-disconnected'])
463 except D.DBusException:
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
479class 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()
7bfa1e06 494 me._state = 'startup'
2ec90437
MW
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
16650038 509 def _reconnect(me, hunoz = None):
2ec90437
MW
510 """
511 Start connecting to the bus.
512
513 If we fail the first time, retry periodically.
514 """
7bfa1e06
MW
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'
2ec90437
MW
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:
7bfa1e06
MW
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:
2ec90437
MW
544 return True
545 me._bus = bus
7bfa1e06 546 me._state = 'connected'
2ec90437 547 bus.call_on_disconnection(me._reconnect)
7bfa1e06 548 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
2ec90437
MW
549 return False
550
551###--------------------------------------------------------------------------
552### TrIPE service.
553
554class 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
571SM.iowatch = GIOWatcher(SM)
572
573def 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 """
29807d89 580 global DBM
22b47552 581 T.Coroutine(kickpeers, name = 'kickpeers').switch()
29807d89
MW
582 DBM = DBusMonitor()
583 DBM.addmon(NetworkManagerMonitor())
a95eb44a 584 DBM.addmon(ConnManMonitor())
29807d89
MW
585 DBM.addmon(MaemoICdMonitor())
586 G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
587 or True))
2ec90437
MW
588
589def 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.
625def cmd_updown(upness):
626 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
627service_info = [('conntrack', VERSION, {
628 'up': (0, None, '', cmd_updown(True)),
2d4998c4
MW
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)
2ec90437
MW
633})]
634
635if __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 --------------------------------------------------