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