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