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