chiark / gitweb /
svc/conntrack.8.in: Insert missing dedent in notification list.
[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)
42import gobject as G
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)
143
144 ## Save the test address. Make sure it's vaguely sensible. The default
145 ## is probably good for most cases, in fact, since that address isn't
146 ## actually in use. Note that we never send packets to the test address;
147 ## we just use it to discover routing information.
148 if cp.has_option('DEFAULT', 'test-addr'):
149 testaddr = cp.get('DEFAULT', 'test-addr')
150 S.inet_aton(testaddr)
151 else:
152 testaddr = '1.2.3.4'
153
154 ## Scan the configuration file and build the groups structure.
155 groups = []
156 for sec in cp.sections():
157 pats = []
158 for tag in cp.options(sec):
159 spec = cp.get(sec, tag).split()
160
161 ## Parse the entry into peer and network.
162 if len(spec) == 1:
163 peer = None
164 net = spec[0]
165 else:
166 peer, net = spec
167
168 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
169 ## and MASK is either a dotted-quad or a single integer N indicating
170 ## a mask with N leading ones followed by trailing zeroes.
171 slash = net.index('/')
172 addr, = unpack('>L', S.inet_aton(net[:slash]))
173 if net.find('.', slash + 1) >= 0:
174 mask, = unpack('>L', S.inet_aton(net[:slash]))
175 else:
176 n = int(net[slash + 1:], 10)
177 mask = (1 << 32) - (1 << 32 - n)
178 pats.append((tag, peer, addr & mask, mask))
179
180 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
181 ## In order to make things vaguely sane, we topologically sort the
182 ## patterns so that more specific patterns are checked first.
183 pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
184 (p and not pp) or \
185 (p == pp and m == (m | mm) and aa == (a & mm)),
186 pats))
187 groups.append((sec, pats))
188
189 ## Done.
190 me.testaddr = testaddr
191 me.groups = groups
192
193### This will be a configuration file.
194CF = None
195
196###--------------------------------------------------------------------------
197### Responding to a network up/down event.
198
199def localaddr(peer):
200 """
201 Return the local IP address used for talking to PEER.
202 """
203 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
204 try:
205 try:
206 sk.connect((peer, 1))
207 addr, _ = sk.getsockname()
208 addr, = unpack('>L', S.inet_aton(addr))
209 return addr
210 except S.error:
211 return None
212 finally:
213 sk.close()
214
215_kick = T.Queue()
216def kickpeers():
217 while True:
218 upness, reason = _kick.get()
219
220 ## Make sure the configuration file is up-to-date. Don't worry if we
221 ## can't do anything useful.
222 try:
223 CF.check()
224 except Exception, exc:
225 SM.warn('conntrack', 'config-file-error',
226 exc.__class__.__name__, str(exc))
227
228 ## Find the current list of peers.
229 peers = SM.list()
230
231 ## Work out the primary IP address.
232 if upness:
233 addr = localaddr(CF.testaddr)
234 if addr is None:
235 upness = False
b10a8c3d
MW
236 else:
237 addr = None
2ec90437
MW
238
239 ## Now decide what to do.
240 changes = []
241 for g, pp in CF.groups:
242
243 ## Find out which peer in the group ought to be active.
b10a8c3d
MW
244 ip = None
245 map = {}
246 want = None
247 for t, p, a, m in pp:
248 if p is None or not upness:
249 ipq = addr
250 else:
251 ipq = localaddr(p)
252 if upness and ip is None and \
253 ipq is not None and (ipq & m) == a:
254 map[t] = 'up'
f2bdb96e
MW
255 if t == 'down' or t.startswith('down/'):
256 want = None
257 else:
258 want = t
b10a8c3d
MW
259 ip = ipq
260 else:
261 map[t] = 'down'
2ec90437
MW
262
263 ## Shut down the wrong ones.
264 found = False
265 for p in peers:
b10a8c3d
MW
266 what = map.get(p, 'leave')
267 if what == 'up':
2ec90437 268 found = True
b10a8c3d 269 elif what == 'down':
cf2e4ea6
MW
270 def _(p = p):
271 try:
272 SM.kill(p)
273 except T.TripeError, exc:
274 if exc.args[0] == 'unknown-peer':
275 ## Inherently racy; don't worry about this.
276 pass
277 else:
278 raise
279 changes.append(_)
2ec90437
MW
280
281 ## Start the right one if necessary.
7b7e3c74 282 if want is not None and not found:
cf2e4ea6
MW
283 def _(want = want):
284 try:
285 SM.svcsubmit('connect', 'active', want)
286 except T.TripeError, exc:
287 SM.warn('conntrack', 'connect-failed', want, *exc.args)
288 changes.append(_)
2ec90437
MW
289
290 ## Commit the changes.
291 if changes:
292 SM.notify('conntrack', upness and 'up' or 'down', *reason)
293 for c in changes: c()
294
295def netupdown(upness, reason):
296 """
297 Add or kill peers according to whether the network is up or down.
298
299 UPNESS is true if the network is up, or false if it's down.
300 """
301
302 _kick.put((upness, reason))
303
304###--------------------------------------------------------------------------
305### NetworkManager monitor.
306
307NM_NAME = 'org.freedesktop.NetworkManager'
308NM_PATH = '/org/freedesktop/NetworkManager'
309NM_IFACE = NM_NAME
310NMCA_IFACE = NM_NAME + '.Connection.Active'
311
312NM_STATE_CONNECTED = 3
313
314class NetworkManagerMonitor (object):
315 """
316 Watch NetworkManager signals for changes in network state.
317 """
318
319 ## Strategy. There are two kinds of interesting state transitions for us.
320 ## The first one is the global are-we-connected state, which we'll use to
321 ## toggle network upness on a global level. The second is which connection
322 ## has the default route, which we'll use to tweak which peer in the peer
323 ## group is active. The former is most easily tracked using the signal
324 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
325 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
326 ## look for when a new connection gains the default route.
327
328 def attach(me, bus):
329 try:
330 nm = bus.get_object(NM_NAME, NM_PATH)
331 state = nm.Get(NM_IFACE, 'State')
332 if state == NM_STATE_CONNECTED:
333 netupdown(True, ['nm', 'initially-connected'])
334 else:
335 netupdown(False, ['nm', 'initially-disconnected'])
336 except D.DBusException:
337 pass
338 bus.add_signal_receiver(me._nm_state, 'StateChanged', NM_IFACE,
339 NM_NAME, NM_PATH)
340 bus.add_signal_receiver(me._nm_connchange,
341 'PropertiesChanged', NMCA_IFACE,
342 NM_NAME, None)
343
344 def _nm_state(me, state):
345 if state == NM_STATE_CONNECTED:
346 netupdown(True, ['nm', 'connected'])
347 else:
348 netupdown(False, ['nm', 'disconnected'])
349
350 def _nm_connchange(me, props):
351 if props.get('Default', False):
352 netupdown(True, ['nm', 'default-connection-change'])
353
354###--------------------------------------------------------------------------
355### Maemo monitor.
356
357ICD_NAME = 'com.nokia.icd'
358ICD_PATH = '/com/nokia/icd'
359ICD_IFACE = ICD_NAME
360
361class MaemoICdMonitor (object):
362 """
363 Watch ICd signals for changes in network state.
364 """
365
366 ## Strategy. ICd only handles one connection at a time in steady state,
367 ## though when switching between connections, it tries to bring the new one
368 ## up before shutting down the old one. This makes life a bit easier than
369 ## it is with NetworkManager. On the other hand, the notifications are
370 ## relative to particular connections only, and the indicator that the old
371 ## connection is down (`IDLE') comes /after/ the new one comes up
372 ## (`CONNECTED'), so we have to remember which one is active.
373
374 def attach(me, bus):
375 try:
376 icd = bus.get_object(ICD_NAME, ICD_PATH)
377 try:
378 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
379 me._iap = iap
380 netupdown(True, ['icd', 'initially-connected', iap])
381 except D.DBusException:
382 me._iap = None
383 netupdown(False, ['icd', 'initially-disconnected'])
384 except D.DBusException:
385 me._iap = None
386 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
387 ICD_NAME, ICD_PATH)
388
389 def _icd_state(me, iap, ty, state, hunoz):
390 if state == 'CONNECTED':
391 me._iap = iap
392 netupdown(True, ['icd', 'connected', iap])
393 elif state == 'IDLE' and iap == me._iap:
394 me._iap = None
395 netupdown(False, ['icd', 'idle'])
396
397###--------------------------------------------------------------------------
398### D-Bus connection tracking.
399
400class DBusMonitor (object):
401 """
402 Maintains a connection to the system D-Bus, and watches for signals.
403
404 If the connection is initially down, or drops for some reason, we retry
405 periodically (every five seconds at the moment). If the connection
406 resurfaces, we reattach the monitors.
407 """
408
409 def __init__(me):
410 """
411 Initialise the object and try to establish a connection to the bus.
412 """
413 me._mons = []
414 me._loop = D.mainloop.glib.DBusGMainLoop()
7bfa1e06 415 me._state = 'startup'
2ec90437
MW
416 me._reconnect()
417
418 def addmon(me, mon):
419 """
420 Add a monitor object to watch for signals.
421
422 MON.attach(BUS) is called, with BUS being the connection to the system
423 bus. MON should query its service's current status and watch for
424 relevant signals.
425 """
426 me._mons.append(mon)
427 if me._bus is not None:
428 mon.attach(me._bus)
429
16650038 430 def _reconnect(me, hunoz = None):
2ec90437
MW
431 """
432 Start connecting to the bus.
433
434 If we fail the first time, retry periodically.
435 """
7bfa1e06
MW
436 if me._state == 'startup':
437 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
438 elif me._state == 'connected':
439 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
440 else:
441 T.aside(SM.notify, 'conntrack', 'dbus-connection',
442 'state=%s' % me._state)
443 me._state == 'reconnecting'
2ec90437
MW
444 me._bus = None
445 if me._try_connect():
446 G.timeout_add_seconds(5, me._try_connect)
447
448 def _try_connect(me):
449 """
450 Actually make a connection attempt.
451
452 If we succeed, attach the monitors.
453 """
454 try:
7bfa1e06
MW
455 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
456 if addr == 'SESSION':
457 bus = D.SessionBus(mainloop = me._loop, private = True)
458 elif addr is not None:
459 bus = D.bus.BusConnection(addr, mainloop = me._loop)
460 else:
461 bus = D.SystemBus(mainloop = me._loop, private = True)
462 for m in me._mons:
463 m.attach(bus)
464 except D.DBusException, e:
2ec90437
MW
465 return True
466 me._bus = bus
7bfa1e06 467 me._state = 'connected'
2ec90437 468 bus.call_on_disconnection(me._reconnect)
7bfa1e06 469 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
2ec90437
MW
470 return False
471
472###--------------------------------------------------------------------------
473### TrIPE service.
474
475class GIOWatcher (object):
476 """
477 Monitor I/O events using glib.
478 """
479 def __init__(me, conn, mc = G.main_context_default()):
480 me._conn = conn
481 me._watch = None
482 me._mc = mc
483 def connected(me, sock):
484 me._watch = G.io_add_watch(sock, G.IO_IN,
485 lambda *hunoz: me._conn.receive())
486 def disconnected(me):
487 G.source_remove(me._watch)
488 me._watch = None
489 def iterate(me):
490 me._mc.iteration(True)
491
492SM.iowatch = GIOWatcher(SM)
493
494def init():
495 """
496 Service initialization.
497
498 Add the D-Bus monitor here, because we might send commands off immediately,
499 and we want to make sure the server connection is up.
500 """
29807d89 501 global DBM
22b47552 502 T.Coroutine(kickpeers, name = 'kickpeers').switch()
29807d89
MW
503 DBM = DBusMonitor()
504 DBM.addmon(NetworkManagerMonitor())
505 DBM.addmon(MaemoICdMonitor())
506 G.timeout_add_seconds(30, lambda: (netupdown(True, ['interval-timer'])
507 or True))
2ec90437
MW
508
509def parse_options():
510 """
511 Parse the command-line options.
512
513 Automatically changes directory to the requested configdir, and turns on
514 debugging. Returns the options object.
515 """
516 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
517 version = '%%prog %s' % VERSION)
518
519 op.add_option('-a', '--admin-socket',
520 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
521 help = 'Select socket to connect to [default %default]')
522 op.add_option('-d', '--directory',
523 metavar = 'DIR', dest = 'dir', default = T.configdir,
524 help = 'Select current diretory [default %default]')
525 op.add_option('-c', '--config',
526 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
527 help = 'Select configuration [default %default]')
528 op.add_option('--daemon', dest = 'daemon',
529 default = False, action = 'store_true',
530 help = 'Become a daemon after successful initialization')
531 op.add_option('--debug', dest = 'debug',
532 default = False, action = 'store_true',
533 help = 'Emit debugging trace information')
534 op.add_option('--startup', dest = 'startup',
535 default = False, action = 'store_true',
536 help = 'Being called as part of the server startup')
537
538 opts, args = op.parse_args()
539 if args: op.error('no arguments permitted')
540 OS.chdir(opts.dir)
541 T._debug = opts.debug
542 return opts
543
544## Service table, for running manually.
545def cmd_updown(upness):
546 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
547service_info = [('conntrack', VERSION, {
548 'up': (0, None, '', cmd_updown(True)),
549 'down': (0, None, '', cmd_updown(False))
550})]
551
552if __name__ == '__main__':
553 opts = parse_options()
554 CF = Config(opts.conf)
555 T.runservices(opts.tripesock, service_info,
556 init = init, daemon = opts.daemon)
557
558###----- That's all, folks --------------------------------------------------