svc/conntrack.in: Write debug trace if we fail to attach to DBus services.
[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###
11ad66c2
MW
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.
2ec90437 17###
11ad66c2
MW
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.
2ec90437
MW
22###
23### You should have received a copy of the GNU General Public License
11ad66c2 24### along with TrIPE. If not, see <https://www.gnu.org/licenses/>.
2ec90437
MW
25
26VERSION = '@VERSION@'
27
28###--------------------------------------------------------------------------
29### External dependencies.
30
31from ConfigParser import RawConfigParser
32from optparse import OptionParser
33import os as OS
34import sys as SYS
35import socket as S
36import mLib as M
37import tripe as T
38import dbus as D
39for i in ['mainloop', 'mainloop.glib']:
40 __import__('dbus.%s' % i)
a69f4417
MW
41try: from gi.repository import GLib as G
42except ImportError: import gobject as G
2ec90437
MW
43from struct import pack, unpack
44
45SM = T.svcmgr
46##__import__('rmcr').__debug = True
47
48###--------------------------------------------------------------------------
49### Utilities.
50
51class struct (object):
52 """A simple container object."""
53 def __init__(me, **kw):
54 me.__dict__.update(kw)
55
56def 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
105class 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)
2d4998c4 143 if T._debug: print '# reread config'
2ec90437
MW
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.
195CF = None
196
c3897a7d 197def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
2d4998c4
MW
198def strmask(m):
199 for i in xrange(33):
200 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return i
201 return straddr(m)
202
203def cmd_showconfig():
204 T.svcinfo('test-addr=%s' % CF.testaddr)
205def cmd_showgroups():
206 for sec, pats in CF.groups:
207 T.svcinfo(sec)
208def 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
2ec90437
MW
220###--------------------------------------------------------------------------
221### Responding to a network up/down event.
222
223def 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()
240def kickpeers():
241 while True:
242 upness, reason = _kick.get()
2d4998c4
MW
243 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
244 select = []
2ec90437
MW
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
b10a8c3d
MW
262 else:
263 addr = None
2d4998c4
MW
264 if not T._debug: pass
265 elif addr: print '# local address = %s' % straddr(addr)
266 else: print '# offline'
2ec90437
MW
267
268 ## Now decide what to do.
269 changes = []
270 for g, pp in CF.groups:
2d4998c4 271 if T._debug: print '# check group %s' % g
2ec90437
MW
272
273 ## Find out which peer in the group ought to be active.
b10a8c3d
MW
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)
2d4998c4
MW
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))
b10a8c3d
MW
285 if upness and ip is None and \
286 ipq is not None and (ipq & m) == a:
2d4998c4 287 if T._debug: print '# %s: SELECTED' % info
b10a8c3d 288 map[t] = 'up'
2d4998c4 289 select.append('%s=%s' % (g, t))
f2bdb96e
MW
290 if t == 'down' or t.startswith('down/'):
291 want = None
292 else:
293 want = t
b10a8c3d
MW
294 ip = ipq
295 else:
296 map[t] = 'down'
2d4998c4 297 if T._debug: print '# %s: skipped' % info
2ec90437
MW
298
299 ## Shut down the wrong ones.
300 found = False
2d4998c4 301 if T._debug: print '# peer-map = %r' % map
2ec90437 302 for p in peers:
b10a8c3d
MW
303 what = map.get(p, 'leave')
304 if what == 'up':
2ec90437 305 found = True
2d4998c4 306 if T._debug: print '# peer %s: already up' % p
b10a8c3d 307 elif what == 'down':
cf2e4ea6
MW
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
2d4998c4 317 if T._debug: print '# peer %s: bring down' % p
cf2e4ea6 318 changes.append(_)
2ec90437
MW
319
320 ## Start the right one if necessary.
7b7e3c74 321 if want is not None and not found:
cf2e4ea6
MW
322 def _(want = want):
323 try:
324 SM.svcsubmit('connect', 'active', want)
325 except T.TripeError, exc:
326 SM.warn('conntrack', 'connect-failed', want, *exc.args)
2d4998c4 327 if T._debug: print '# peer %s: bring up' % want
cf2e4ea6 328 changes.append(_)
2ec90437
MW
329
330 ## Commit the changes.
331 if changes:
2d4998c4 332 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
2ec90437
MW
333 for c in changes: c()
334
335def 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
347NM_NAME = 'org.freedesktop.NetworkManager'
348NM_PATH = '/org/freedesktop/NetworkManager'
349NM_IFACE = NM_NAME
350NMCA_IFACE = NM_NAME + '.Connection.Active'
351
2079efa1
MW
352NM_STATE_CONNECTED = 3 #obsolete
353NM_STATE_CONNECTED_LOCAL = 50
354NM_STATE_CONNECTED_SITE = 60
355NM_STATE_CONNECTED_GLOBAL = 70
356NM_CONNSTATES = set([NM_STATE_CONNECTED,
357 NM_STATE_CONNECTED_LOCAL,
358 NM_STATE_CONNECTED_SITE,
359 NM_STATE_CONNECTED_GLOBAL])
2ec90437
MW
360
361class NetworkManagerMonitor (object):
362 """
363 Watch NetworkManager signals for changes in network state.
364 """
365
366 ## Strategy. There are two kinds of interesting state transitions for us.
367 ## The first one is the global are-we-connected state, which we'll use to
368 ## toggle network upness on a global level. The second is which connection
369 ## has the default route, which we'll use to tweak which peer in the peer
370 ## group is active. The former is most easily tracked using the signal
371 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
372 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
373 ## look for when a new connection gains the default route.
374
375 def attach(me, bus):
376 try:
377 nm = bus.get_object(NM_NAME, NM_PATH)
378 state = nm.Get(NM_IFACE, 'State')
2079efa1 379 if state in NM_CONNSTATES:
2ec90437
MW
380 netupdown(True, ['nm', 'initially-connected'])
381 else:
382 netupdown(False, ['nm', 'initially-disconnected'])
bd9bd714
MW
383 except D.DBusException, e:
384 if T._debug: print '# exception attaching to network-manager: %s' % e
2079efa1
MW
385 bus.add_signal_receiver(me._nm_state, 'StateChanged',
386 NM_IFACE, NM_NAME, NM_PATH)
387 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
388 NMCA_IFACE, NM_NAME, None)
2ec90437
MW
389
390 def _nm_state(me, state):
2079efa1 391 if state in NM_CONNSTATES:
2ec90437
MW
392 netupdown(True, ['nm', 'connected'])
393 else:
394 netupdown(False, ['nm', 'disconnected'])
395
396 def _nm_connchange(me, props):
397 if props.get('Default', False):
398 netupdown(True, ['nm', 'default-connection-change'])
399
a95eb44a
MW
400##--------------------------------------------------------------------------
401### Connman monitor.
402
403CM_NAME = 'net.connman'
404CM_PATH = '/'
405CM_IFACE = 'net.connman.Manager'
406
407class ConnManMonitor (object):
408 """
409 Watch ConnMan signls for changes in network state.
410 """
411
412 ## Strategy. Everything seems to be usefully encoded in the `State'
413 ## property. If it's `offline', `idle' or `ready' then we don't expect a
414 ## network connection. During handover from one network to another, the
415 ## property passes through `ready' to `online'.
416
417 def attach(me, bus):
418 try:
419 cm = bus.get_object(CM_NAME, CM_PATH)
420 props = cm.GetProperties(dbus_interface = CM_IFACE)
421 state = props['State']
422 netupdown(state == 'online', ['connman', 'initially-%s' % state])
bd9bd714
MW
423 except D.DBusException, e:
424 if T._debug: print '# exception attaching to connman: %s' % e
a95eb44a
MW
425 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
426 CM_IFACE, CM_NAME, CM_PATH)
427
428 def _cm_state(me, prop, value):
429 if prop != 'State': return
430 netupdown(value == 'online', ['connman', value])
431
2ec90437
MW
432###--------------------------------------------------------------------------
433### Maemo monitor.
434
435ICD_NAME = 'com.nokia.icd'
436ICD_PATH = '/com/nokia/icd'
437ICD_IFACE = ICD_NAME
438
439class MaemoICdMonitor (object):
440 """
441 Watch ICd signals for changes in network state.
442 """
443
444 ## Strategy. ICd only handles one connection at a time in steady state,
445 ## though when switching between connections, it tries to bring the new one
446 ## up before shutting down the old one. This makes life a bit easier than
447 ## it is with NetworkManager. On the other hand, the notifications are
448 ## relative to particular connections only, and the indicator that the old
449 ## connection is down (`IDLE') comes /after/ the new one comes up
450 ## (`CONNECTED'), so we have to remember which one is active.
451
452 def attach(me, bus):
453 try:
454 icd = bus.get_object(ICD_NAME, ICD_PATH)
455 try:
456 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
457 me._iap = iap
458 netupdown(True, ['icd', 'initially-connected', iap])
459 except D.DBusException:
460 me._iap = None
461 netupdown(False, ['icd', 'initially-disconnected'])
bd9bd714
MW
462 except D.DBusException, e:
463 if T._debug: print '# exception attaching to icd: %s' % e
2ec90437
MW
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 --------------------------------------------------