X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~mdw/git/tripe/blobdiff_plain/22b4755242c39bc8e948e4b714bea80433882a11..31d7aa8d987530b11fa208ac87ffd1d31c22fdf3:/svc/conntrack.in diff --git a/svc/conntrack.in b/svc/conntrack.in index eabdc2b1..99491b1c 100644 --- a/svc/conntrack.in +++ b/svc/conntrack.in @@ -10,19 +10,18 @@ ### ### This file is part of Trivial IP Encryption (TrIPE). ### -### TrIPE is free software; you can redistribute it and/or modify -### it under the terms of the GNU General Public License as published by -### the Free Software Foundation; either version 2 of the License, or -### (at your option) any later version. +### TrIPE is free software: you can redistribute it and/or modify it under +### the terms of the GNU General Public License as published by the Free +### Software Foundation; either version 3 of the License, or (at your +### option) any later version. ### -### TrIPE is distributed in the hope that it will be useful, -### but WITHOUT ANY WARRANTY; without even the implied warranty of -### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -### GNU General Public License for more details. +### TrIPE is distributed in the hope that it will be useful, but WITHOUT +### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +### FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +### for more details. ### ### You should have received a copy of the GNU General Public License -### along with TrIPE; if not, write to the Free Software Foundation, -### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +### along with TrIPE. If not, see . VERSION = '@VERSION@' @@ -37,9 +36,11 @@ import socket as S import mLib as M import tripe as T import dbus as D +import re as RX for i in ['mainloop', 'mainloop.glib']: __import__('dbus.%s' % i) -import gobject as G +try: from gi.repository import GLib as G +except ImportError: import gobject as G from struct import pack, unpack SM = T.svcmgr @@ -53,46 +54,59 @@ class struct (object): def __init__(me, **kw): me.__dict__.update(kw) -def toposort(cmp, things): - """ - Generate the THINGS in an order consistent with a given partial order. - - The function CMP(X, Y) should return true if X must precede Y, and false if - it doesn't care. If X and Y are equal then it should return false. - - The THINGS may be any finite iterable; it is converted to a list - internally. - """ - - ## Make sure we can index the THINGS, and prepare an ordering table. - ## What's going on? The THINGS might not have a helpful equality - ## predicate, so it's easier to work with indices. The ordering table will - ## remember which THINGS (by index) are considered greater than other - ## things. - things = list(things) - n = len(things) - order = [{} for i in xrange(n)] - rorder = [{} for i in xrange(n)] - for i in xrange(n): - for j in xrange(n): - if i != j and cmp(things[i], things[j]): - order[j][i] = True - rorder[i][j] = True - - ## Now we can do the sort. - out = [] - while True: - done = True - for i in xrange(n): - if order[i] is not None: - done = False - if len(order[i]) == 0: - for j in rorder[i]: - del order[j][i] - yield things[i] - order[i] = None - if done: - break +###-------------------------------------------------------------------------- +### Address manipulation. + +class InetAddress (object): + def __init__(me, addrstr, maskstr = None): + me.addr = me._addrstr_to_int(addrstr) + if maskstr is None: + me.mask = -1 + elif maskstr.isdigit(): + me.mask = (1 << 32) - (1 << 32 - int(maskstr)) + else: + me.mask = me._addrstr_to_int(maskstr) + if me.addr&~me.mask: + raise ValueError('network contains bits set beyond mask') + def _addrstr_to_int(me, addrstr): + return unpack('>L', S.inet_aton(addrstr))[0] + def _int_to_addrstr(me, n): + return S.inet_ntoa(pack('>L', n)) + def sockaddr(me, port = 0): + if me.mask != -1: raise ValueError('not a simple address') + return me._int_to_addrstr(me.addr), port + def __str__(me): + addrstr = me._int_to_addrstr(me.addr) + if me.mask == -1: + return addrstr + else: + inv = me.mask ^ ((1 << 32) - 1) + if (inv&(inv + 1)) == 0: + return '%s/%d' % (addrstr, 32 - inv.bit_length()) + else: + return '%s/%s' % (addrstr, me._int_to_addrstr(me.mask)) + def withinp(me, net): + if (me.mask&net.mask) != net.mask: return False + if (me.addr ^ net.addr)&net.mask: return False + return True + def eq(me, other): + if me.mask != other.mask: return False + if me.addr != other.addr: return False + return True + @classmethod + def from_sockaddr(cls, sa): + addr, port = (lambda a, p: (a, p))(*sa) + return cls(addr), port + +def parse_address(addrstr, maskstr = None): + return InetAddress(addrstr, maskstr) + +def parse_net(netstr): + try: sl = netstr.index('/') + except ValueError: raise ValueError('missing mask') + return parse_address(netstr[:sl], netstr[sl + 1:]) + +def straddr(a): return a is None and '#' or str(a) ###-------------------------------------------------------------------------- ### Parse the configuration file. @@ -102,18 +116,32 @@ def toposort(cmp, things): ## this service are largely going to be satellite notes, I don't think ## scalability's going to be a problem. +TESTADDR = InetAddress('1.2.3.4') + +CONFSYNTAX = [ + ('COMMENT', RX.compile(r'^\s*($|[;#])')), + ('GRPHDR', RX.compile(r'^\s*\[(.*)\]\s*$')), + ('ASSGN', RX.compile(r'\s*([\w.-]+)\s*[:=]\s*(|\S|\S.*\S)\s*$'))] + +class ConfigError (Exception): + def __init__(me, file, lno, msg): + me.file = file + me.lno = lno + me.msg = msg + def __str__(me): + return '%s:%d: %s' % (me.file, me.lno, me.msg) + class Config (object): """ Represents a configuration file. The most interesting thing is probably the `groups' slot, which stores a list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a - list of (TAG, PEER, ADDR, MASK) triples. The implication is that there - should be precisely one peer with a name matching NAME-*, and that it - should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such - that the host's primary IP address (if PEER is None -- or the IP address it - would use for communicating with PEER) is within the network defined by - ADDR/MASK. + list of (TAG, PEER, NET) triples. The implication is that there should be + precisely one peer from the set, and that it should be named TAG, where + (TAG, PEER, NET) is the first triple such that the host's primary IP + address (if PEER is None -- or the IP address it would use for + communicating with PEER) is within the NET. """ def __init__(me, file): @@ -136,63 +164,118 @@ class Config (object): Internal function to update the configuration from the underlying file. """ - ## Read the configuration. We have no need of the fancy substitutions, - ## so turn them all off. - cp = RawConfigParser() - cp.read(me._file) - - ## Save the test address. Make sure it's vaguely sensible. The default - ## is probably good for most cases, in fact, since that address isn't - ## actually in use. Note that we never send packets to the test address; - ## we just use it to discover routing information. - if cp.has_option('DEFAULT', 'test-addr'): - testaddr = cp.get('DEFAULT', 'test-addr') - S.inet_aton(testaddr) - else: - testaddr = '1.2.3.4' - - ## Scan the configuration file and build the groups structure. - groups = [] - for sec in cp.sections(): - pats = [] - for tag in cp.options(sec): - spec = cp.get(sec, tag).split() - - ## Parse the entry into peer and network. - if len(spec) == 1: - peer = None - net = spec[0] + if T._debug: print '# reread config' + + ## Initial state. + testaddr = None + groups = {} + grpname = None + grplist = [] + + ## Open the file and start reading. + with open(me._file) as f: + lno = 0 + for line in f: + lno += 1 + for tag, rx in CONFSYNTAX: + m = rx.match(line) + if m: break else: - peer, net = spec - - ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad, - ## and MASK is either a dotted-quad or a single integer N indicating - ## a mask with N leading ones followed by trailing zeroes. - slash = net.index('/') - addr, = unpack('>L', S.inet_aton(net[:slash])) - if net.find('.', slash + 1) >= 0: - mask, = unpack('>L', S.inet_aton(net[:slash])) - else: - n = int(net[slash + 1:], 10) - mask = (1 << 32) - (1 << 32 - n) - pats.append((tag, peer, addr & mask, mask)) - - ## Annoyingly, RawConfigParser doesn't preserve the order of options. - ## In order to make things vaguely sane, we topologically sort the - ## patterns so that more specific patterns are checked first. - pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \ - (p and not pp) or \ - (p == pp and m == (m | mm) and aa == (a & mm)), - pats)) - groups.append((sec, pats)) + raise ConfigError(me._file, lno, 'failed to parse line: %r' % line) + + if tag == 'COMMENT': + ## A comment. Ignore it and hope it goes away. + + continue + + elif tag == 'GRPHDR': + ## A group header. Flush the old group and start a new one. + newname = m.group(1) + + if grpname is not None: groups[grpname] = grplist + if newname in groups: + raise ConfigError(me._file, lno, + "duplicate group name `%s'" % newname) + grpname = newname + grplist = [] + + elif tag == 'ASSGN': + ## An assignment. Deal with it. + name, value = m.group(1), m.group(2) + + if grpname is None: + ## We're outside of any group, so this is a global configuration + ## tweak. + + if name == 'test-addr': + for astr in value.split(): + try: + a = parse_address(astr) + except Exception, e: + raise ConfigError(me._file, lno, + "invalid IP address `%s': %s" % + (astr, e)) + if testaddr is not None: + raise ConfigError(me._file, lno, 'duplicate test-address') + testaddr = a + else: + raise ConfigError(me._file, lno, + "unknown global option `%s'" % name) + + else: + ## Parse a pattern and add it to the group. + spec = value.split() + i = 0 + + ## Check for an explicit target address. + if i >= len(spec) or spec[i].find('/') >= 0: + peer = None + else: + try: + peer = parse_address(spec[i]) + except Exception, e: + raise ConfigError(me._file, lno, + "invalid IP address `%s': %s" % + (spec[i], e)) + i += 1 + + ## Parse the local network. + if len(spec) != i + 1: + raise ConfigError(me._file, lno, 'no network defined') + try: + net = parse_net(spec[i]) + except Exception, e: + raise ConfigError(me._file, lno, + "invalid IP network `%s': %s" % + (spec[i], e)) + + ## Add this entry to the list. + grplist.append((name, peer, net)) + + ## Fill in the default test address if necessary. + if testaddr is None: testaddr = TESTADDR ## Done. + if grpname is not None: groups[grpname] = grplist me.testaddr = testaddr me.groups = groups ### This will be a configuration file. CF = None +def cmd_showconfig(): + T.svcinfo('test-addr=%s' % CF.testaddr) +def cmd_showgroups(): + for g in sorted(CF.groups.iterkeys()): + T.svcinfo(g) +def cmd_showgroup(g): + try: pats = CF.groups[g] + except KeyError: raise T.TripeJobError('unknown-group', g) + for t, p, n in pats: + T.svcinfo('peer', t, + 'target', p and str(p) or '(default)', + 'net', str(n)) + ###-------------------------------------------------------------------------- ### Responding to a network up/down event. @@ -203,19 +286,51 @@ def localaddr(peer): sk = S.socket(S.AF_INET, S.SOCK_DGRAM) try: try: - sk.connect((peer, 1)) - addr, _ = sk.getsockname() - addr, = unpack('>L', S.inet_aton(addr)) - return addr + sk.connect(peer.sockaddr(1)) + addr = sk.getsockname() + return InetAddress.from_sockaddr(addr)[0] except S.error: return None finally: sk.close() _kick = T.Queue() +_delay = None + +def cancel_delay(): + global _delay + if _delay is not None: + if T._debug: print '# cancel delayed kick' + G.source_remove(_delay) + _delay = None + +def netupdown(upness, reason): + """ + Add or kill peers according to whether the network is up or down. + + UPNESS is true if the network is up, or false if it's down. + """ + + _kick.put((upness, reason)) + +def delay_netupdown(upness, reason): + global _delay + cancel_delay() + def _func(): + global _delay + if T._debug: print '# delayed %s: %s' % (upness, reason) + _delay = None + netupdown(upness, reason) + return False + if T._debug: print '# delaying %s: %s' % (upness, reason) + _delay = G.timeout_add(2000, _func) + def kickpeers(): while True: upness, reason = _kick.get() + if T._debug: print '# kickpeers %s: %s' % (upness, reason) + select = [] + cancel_delay() ## Make sure the configuration file is up-to-date. Don't worry if we ## can't do anything useful. @@ -233,59 +348,97 @@ def kickpeers(): addr = localaddr(CF.testaddr) if addr is None: upness = False + else: + addr = None + if not T._debug: pass + elif addr: print '# local address = %s' % straddr(addr) + else: print '# offline' ## Now decide what to do. changes = [] - for g, pp in CF.groups: + for g, pp in CF.groups.iteritems(): + if T._debug: print '# check group %s' % g ## Find out which peer in the group ought to be active. - want = None # unequal to any string - if upness: - for t, p, a, m in pp: - if p is None: - aq = addr + ip = None + statemap = {} + want = None + for t, p, n in pp: + if p is None or not upness: + ipq = addr + else: + ipq = localaddr(p) + if T._debug: + info = 'peer=%s; target=%s; net=%s; local=%s' % ( + t, p or '(default)', n, straddr(ipq)) + if upness and ip is None and \ + ipq is not None and ipq.withinp(n): + if T._debug: print '# %s: SELECTED' % info + statemap[t] = 'up' + select.append('%s=%s' % (g, t)) + if t == 'down' or t.startswith('down/'): + want = None else: - aq = localaddr(p) - if aq is not None and (aq & m) == a: want = t - break + ip = ipq + else: + statemap[t] = 'down' + if T._debug: print '# %s: skipped' % info ## Shut down the wrong ones. found = False + if T._debug: print '# peer-map = %r' % statemap for p in peers: - if p == want: + what = statemap.get(p, 'leave') + if what == 'up': found = True - elif p.startswith(g) and p != want: - changes.append(lambda p=p: SM.kill(p)) + if T._debug: print '# peer %s: already up' % p + elif what == 'down': + def _(p = p): + try: + SM.kill(p) + except T.TripeError, exc: + if exc.args[0] == 'unknown-peer': + ## Inherently racy; don't worry about this. + pass + else: + raise + if T._debug: print '# peer %s: bring down' % p + changes.append(_) ## Start the right one if necessary. if want is not None and not found: - changes.append(lambda: T._simple(SM.svcsubmit('connect', 'active', - want))) + def _(want = want): + try: + list(SM.svcsubmit('connect', 'active', want)) + except T.TripeError, exc: + SM.warn('conntrack', 'connect-failed', want, *exc.args) + if T._debug: print '# peer %s: bring up' % want + changes.append(_) ## Commit the changes. if changes: - SM.notify('conntrack', upness and 'up' or 'down', *reason) + SM.notify('conntrack', upness and 'up' or 'down', *select + reason) for c in changes: c() -def netupdown(upness, reason): - """ - Add or kill peers according to whether the network is up or down. - - UPNESS is true if the network is up, or false if it's down. - """ - - _kick.put((upness, reason)) - ###-------------------------------------------------------------------------- ### NetworkManager monitor. +DBPROPS_IFACE = 'org.freedesktop.DBus.Properties' + NM_NAME = 'org.freedesktop.NetworkManager' NM_PATH = '/org/freedesktop/NetworkManager' NM_IFACE = NM_NAME NMCA_IFACE = NM_NAME + '.Connection.Active' -NM_STATE_CONNECTED = 3 +NM_STATE_CONNECTED = 3 #obsolete +NM_STATE_CONNECTED_LOCAL = 50 +NM_STATE_CONNECTED_SITE = 60 +NM_STATE_CONNECTED_GLOBAL = 70 +NM_CONNSTATES = set([NM_STATE_CONNECTED, + NM_STATE_CONNECTED_LOCAL, + NM_STATE_CONNECTED_SITE, + NM_STATE_CONNECTED_GLOBAL]) class NetworkManagerMonitor (object): """ @@ -304,28 +457,59 @@ class NetworkManagerMonitor (object): def attach(me, bus): try: nm = bus.get_object(NM_NAME, NM_PATH) - state = nm.Get(NM_IFACE, 'State') - if state == NM_STATE_CONNECTED: + state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE) + if state in NM_CONNSTATES: netupdown(True, ['nm', 'initially-connected']) else: netupdown(False, ['nm', 'initially-disconnected']) - except D.DBusException: - pass - bus.add_signal_receiver(me._nm_state, 'StateChanged', NM_IFACE, - NM_NAME, NM_PATH) - bus.add_signal_receiver(me._nm_connchange, - 'PropertiesChanged', NMCA_IFACE, - NM_NAME, None) + except D.DBusException, e: + if T._debug: print '# exception attaching to network-manager: %s' % e + bus.add_signal_receiver(me._nm_state, 'StateChanged', + NM_IFACE, NM_NAME, NM_PATH) + bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged', + NMCA_IFACE, NM_NAME, None) def _nm_state(me, state): - if state == NM_STATE_CONNECTED: - netupdown(True, ['nm', 'connected']) + if state in NM_CONNSTATES: + delay_netupdown(True, ['nm', 'connected']) else: - netupdown(False, ['nm', 'disconnected']) + delay_netupdown(False, ['nm', 'disconnected']) def _nm_connchange(me, props): - if props.get('Default', False): - netupdown(True, ['nm', 'default-connection-change']) + if props.get('Default', False) or props.get('Default6', False): + delay_netupdown(True, ['nm', 'default-connection-change']) + +##-------------------------------------------------------------------------- +### Connman monitor. + +CM_NAME = 'net.connman' +CM_PATH = '/' +CM_IFACE = 'net.connman.Manager' + +class ConnManMonitor (object): + """ + Watch ConnMan signls for changes in network state. + """ + + ## Strategy. Everything seems to be usefully encoded in the `State' + ## property. If it's `offline', `idle' or `ready' then we don't expect a + ## network connection. During handover from one network to another, the + ## property passes through `ready' to `online'. + + def attach(me, bus): + try: + cm = bus.get_object(CM_NAME, CM_PATH) + props = cm.GetProperties(dbus_interface = CM_IFACE) + state = props['State'] + netupdown(state == 'online', ['connman', 'initially-%s' % state]) + except D.DBusException, e: + if T._debug: print '# exception attaching to connman: %s' % e + bus.add_signal_receiver(me._cm_state, 'PropertyChanged', + CM_IFACE, CM_NAME, CM_PATH) + + def _cm_state(me, prop, value): + if prop != 'State': return + delay_netupdown(value == 'online', ['connman', value]) ###-------------------------------------------------------------------------- ### Maemo monitor. @@ -357,7 +541,8 @@ class MaemoICdMonitor (object): except D.DBusException: me._iap = None netupdown(False, ['icd', 'initially-disconnected']) - except D.DBusException: + except D.DBusException, e: + if T._debug: print '# exception attaching to icd: %s' % e me._iap = None bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE, ICD_NAME, ICD_PATH) @@ -365,10 +550,10 @@ class MaemoICdMonitor (object): def _icd_state(me, iap, ty, state, hunoz): if state == 'CONNECTED': me._iap = iap - netupdown(True, ['icd', 'connected', iap]) + delay_netupdown(True, ['icd', 'connected', iap]) elif state == 'IDLE' and iap == me._iap: me._iap = None - netupdown(False, ['icd', 'idle']) + delay_netupdown(False, ['icd', 'idle']) ###-------------------------------------------------------------------------- ### D-Bus connection tracking. @@ -388,6 +573,7 @@ class DBusMonitor (object): """ me._mons = [] me._loop = D.mainloop.glib.DBusGMainLoop() + me._state = 'startup' me._reconnect() def addmon(me, mon): @@ -402,12 +588,20 @@ class DBusMonitor (object): if me._bus is not None: mon.attach(me._bus) - def _reconnect(me): + def _reconnect(me, hunoz = None): """ Start connecting to the bus. If we fail the first time, retry periodically. """ + if me._state == 'startup': + T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup') + elif me._state == 'connected': + T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost') + else: + T.aside(SM.notify, 'conntrack', 'dbus-connection', + 'state=%s' % me._state) + me._state == 'reconnecting' me._bus = None if me._try_connect(): G.timeout_add_seconds(5, me._try_connect) @@ -419,13 +613,21 @@ class DBusMonitor (object): If we succeed, attach the monitors. """ try: - bus = D.SystemBus(mainloop = me._loop, private = True) - except D.DBusException: + addr = OS.getenv('TRIPE_CONNTRACK_BUS') + if addr == 'SESSION': + bus = D.SessionBus(mainloop = me._loop, private = True) + elif addr is not None: + bus = D.bus.BusConnection(addr, mainloop = me._loop) + else: + bus = D.SystemBus(mainloop = me._loop, private = True) + for m in me._mons: + m.attach(bus) + except D.DBusException, e: return True me._bus = bus + me._state = 'connected' bus.call_on_disconnection(me._reconnect) - for m in me._mons: - m.attach(bus) + T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected') return False ###-------------------------------------------------------------------------- @@ -457,12 +659,15 @@ def init(): Add the D-Bus monitor here, because we might send commands off immediately, and we want to make sure the server connection is up. """ + global DBM T.Coroutine(kickpeers, name = 'kickpeers').switch() - dbm = DBusMonitor() - dbm.addmon(NetworkManagerMonitor()) - dbm.addmon(MaemoICdMonitor()) - G.timeout_add_seconds(300, lambda: (netupdown(True, ['interval-timer']) - or True)) + DBM = DBusMonitor() + DBM.addmon(NetworkManagerMonitor()) + DBM.addmon(ConnManMonitor()) + DBM.addmon(MaemoICdMonitor()) + G.timeout_add_seconds(30, lambda: (_delay is not None or + netupdown(True, ['interval-timer']) or + True)) def parse_options(): """ @@ -504,7 +709,10 @@ def cmd_updown(upness): return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args)) service_info = [('conntrack', VERSION, { 'up': (0, None, '', cmd_updown(True)), - 'down': (0, None, '', cmd_updown(False)) + 'down': (0, None, '', cmd_updown(False)), + 'show-config': (0, 0, '', cmd_showconfig), + 'show-groups': (0, 0, '', cmd_showgroups), + 'show-group': (1, 1, 'GROUP', cmd_showgroup) })] if __name__ == '__main__':