re6stnet 18.9 KB
Newer Older
1
#!/usr/bin/python
2
import atexit, errno, logging, os, signal, socket
3
import sqlite3, subprocess, sys, time, threading
4
from collections import deque
5
from re6st import ctl, db, plib, tunnel, utils, version, x509
6
from re6st.utils import exit
7

8 9
class ReexecException(Exception):
    pass
10

Guillaume Bury's avatar
Guillaume Bury committed
11
def getConfig():
Julien Muchembled's avatar
Julien Muchembled committed
12
    parser = utils.ArgParser(fromfile_prefix_chars='@',
Julien Muchembled's avatar
Julien Muchembled committed
13
        description="Resilient virtual private network application.")
Guillaume Bury's avatar
Guillaume Bury committed
14
    _ = parser.add_argument
15
    _('-V', '--version', action='version', version=version.version)
16

17
    _('--ip', action='append', default=[],
Julien Muchembled's avatar
Julien Muchembled committed
18
        help="IP address advertised to other nodes. Special values:\n"
19
             "- upnp: redirect ports when UPnP device is found\n"
Julien Muchembled's avatar
Julien Muchembled committed
20
             "- any: ask peers our IP\n"
21 22
             " (default: like 'upnp' if miniupnpc is installed,\n"
             "  otherwise like 'any')")
23
    _('--registry', metavar='URL', required=True,
Julien Muchembled's avatar
Julien Muchembled committed
24
        help="Public HTTP URL of the registry, for bootstrapping.")
25
    _('-l', '--log', default='/var/log/re6stnet',
Julien Muchembled's avatar
Julien Muchembled committed
26 27 28 29
        help="Path to the directory used for log files:\n"
             "- re6stnet.log: log file of re6stnet itself\n"
             "- babeld.log: log file of router\n"
             "- <iface>.log: 1 file per spawned OpenVPN\n")
30
    _('-s', '--state', default='/var/lib/re6stnet',
Julien Muchembled's avatar
Julien Muchembled committed
31 32 33 34 35 36 37
        help="Path to re6stnet state directory:\n"
             "- peers.db: cache of peer addresses\n"
             "- babeld.state: see option -S of babeld\n")
    _('-v', '--verbose', default=1, type=int, metavar='LEVEL',
        help="Log level of re6stnet itself. 0 disables logging."
             " Use SIGUSR1 to reopen log."
             " See also --babel-verb and --verb for logs of spawned processes.")
38
    _('-i', '--interface', action='append', dest='iface_list', default=[],
Julien Muchembled's avatar
Julien Muchembled committed
39 40
        help="Extra interface for LAN discovery. Highly recommanded if there"
             " are other re6st node on the same network segment.")
41
    _('-I', '--main-interface', metavar='IFACE', default='lo',
42
        help="Set re6stnet IP on given interface. Any interface not used for"
43
             " tunnelling can be chosen.")
Julien Muchembled's avatar
Julien Muchembled committed
44 45
    _('--up', metavar='CMD',
        help="Shell command to run after successful initialization.")
Julien Muchembled's avatar
Julien Muchembled committed
46 47 48 49
    _('--daemon', action='append', metavar='CMD',
        help="Same as --up, but run in background: the command will be killed"
             " at exit (with a TERM signal, followed by KILL 5 seconds later"
             " if process is still alive).")
50 51 52 53
    _('--test', metavar='EXPR',
        help="Exit after configuration parsing. Status code is the"
             " result of the given Python expression. For example:\n"
             "  main_interface != 'eth0'")
54

Julien Muchembled's avatar
Julien Muchembled committed
55
    _ = parser.add_argument_group('routing').add_argument
56 57 58
    _('-B', dest='babel_args', metavar='ARG', action='append', default=[],
        help="Extra arguments to forward to Babel.")
    _('--babel-pidfile', metavar='PID', default='/var/run/re6st-babeld.pid',
Julien Muchembled's avatar
Julien Muchembled committed
59 60
        help="Specify a file to write our process id to"
             " (option -I of Babel).")
61 62 63
    _('--control-socket', metavar='CTL_SOCK', default=ctl.SOCK_PATH,
        help="Socket path to use for communication between re6stnet and babeld"
             " (option -R of Babel).")
Guillaume Bury's avatar
Guillaume Bury committed
64
    _('--hello', type=int, default=15,
Julien Muchembled's avatar
Julien Muchembled committed
65 66 67 68 69
        help="Hello interval in seconds, for both wired and wireless"
             " connections. OpenVPN ping-exit option is set to 4 times the"
             " hello interval. It takes between 3 and 4 times the"
             " hello interval for Babel to re-establish connection with a"
             " node for which the direct connection has been cut.")
70
    _('--table', type=int, default=42,
71 72 73 74 75 76 77 78
        help="Use given table id. Set 0 to use the main table, if you want to"
             " access internet via this network (in this case, make sure you"
             " don't already have a default route). Don't use this option with"
             " --gateway (main table is automatically used).")
    _('--gateway', action='store_true',
        help="Act as a gateway for this network (the default route will be"
             " exported). Do never use it if you don't know what it means.")

Julien Muchembled's avatar
Julien Muchembled committed
79
    _ = parser.add_argument_group('tunnelling').add_argument
80 81 82 83 84
    _('-O', dest='openvpn_args', metavar='ARG', action='append', default=[],
        help="Extra arguments to forward to both server and client OpenVPN"
             " subprocesses. Often used to configure verbosity.")
    _('--ovpnlog', action='store_true',
        help="Tell each OpenVPN subprocess to log to a dedicated file.")
85
    _('--encrypt', action='store_true',
Julien Muchembled's avatar
Julien Muchembled committed
86 87 88
        help='Specify that tunnels should be encrypted.')
    _('--pp', nargs=2, action='append', metavar=('PORT', 'PROTO'),
        help="Port and protocol to be announced to other peers, ordered by"
89
             " preference. For each protocol (udp, tcp, udp6, tcp6), start one"
Julien Muchembled's avatar
Julien Muchembled committed
90 91
             " openvpn server on the first given port."
             " (default: --pp 1194 udp --pp 1194 tcp)")
92
    _('--dh',
Julien Muchembled's avatar
Julien Muchembled committed
93 94
        help='File containing Diffie-Hellman parameters in .pem format')
    _('--ca', required=True, help=parser._ca_help)
Guillaume Bury's avatar
Guillaume Bury committed
95
    _('--cert', required=True,
Julien Muchembled's avatar
Julien Muchembled committed
96 97
        help="Local peer's signed certificate in .pem format."
             " Common name defines the allocated prefix in the network.")
98
    _('--key', required=True,
Julien Muchembled's avatar
Julien Muchembled committed
99
        help="Local peer's private key in .pem format.")
100 101 102 103 104 105
    _('--client-count', default=10, type=int,
        help="Number of client tunnels to set up.")
    _('--max-clients', type=int,
        help="Maximum number of accepted clients per OpenVPN server. (default:"
             " client-count * 2, which actually represents the average number"
             " of tunnels to other peers)")
106
    _('--tunnel-refresh', default=300, type=int,
Julien Muchembled's avatar
Julien Muchembled committed
107 108
        help="Interval in seconds between two tunnel refresh: the worst"
             " tunnel is closed if the number of client tunnels has reached"
109
             " its maximum number (client-count).")
110 111
    _('--remote-gateway', action='append', dest='gw_list',
        help="Force each tunnel to be created through one the given gateways,"
112
             " in a round-robin fashion.")
113 114
    _('--disable-proto', action='append',
        choices=('none', 'udp', 'tcp', 'udp6', 'tcp6'), default=['udp', 'udp6'],
115 116
        help="Do never try to create tunnels using given protocols."
             " 'none' has precedence over other options.")
117 118 119 120
    _('--client', metavar='HOST,PORT,PROTO[;...]',
        help="Do not run any OpenVPN server, but only 1 OpenVPN client,"
             " with specified remotes. Any other option not required in this"
             " mode is ignored (e.g. client-count, max-clients, etc.)")
121 122 123
    _('--neighbour', metavar='CN', action='append', default=[],
        help="List of peers that should be reachable directly, by creating"
             " tunnels if necesssary.")
124

Guillaume Bury's avatar
Guillaume Bury committed
125
    return parser.parse_args()
Ulysse Beaugnon's avatar
Ulysse Beaugnon committed
126

127
def main():
128
    # Get arguments
Guillaume Bury's avatar
Guillaume Bury committed
129
    config = getConfig()
130 131
    cert = x509.Cert(config.ca, config.key, config.cert)
    config.openvpn_args += cert.openvpn_args
132
    # TODO: verify certificates (should we moved to M2Crypto ?)
133

134 135 136
    if config.test:
        sys.exit(eval(config.test, None, config.__dict__))

137
    # Set logging
138
    utils.setupLog(config.verbose, os.path.join(config.log, 're6stnet.log'))
139

140 141
    logging.trace("Environment: %r", os.environ)
    logging.trace("Configuration: %r", config)
142 143
    utils.makedirs(config.state)
    db_path = os.path.join(config.state, 'peers.db')
144 145
    if config.ovpnlog:
        plib.ovpn_log = config.log
146

147 148
    exit.signal(0, signal.SIGINT, signal.SIGTERM)
    exit.signal(-1, signal.SIGHUP, signal.SIGUSR2)
149

150 151
    next_renew = cert.maybeRenew(config.registry)
    network = cert.network
152

153 154 155
    if config.max_clients is None:
        config.max_clients = config.client_count * 2

156 157
    if 'none' in config.disable_proto:
        config.disable_proto = ()
158 159 160 161 162
    if not config.table:
        # Make sure we won't tunnel over re6st.
        config.disable_proto = tuple(set(('tcp6', 'udp6')).union(
            config.disable_proto))
    address = ()
163
    server_tunnels = {}
164
    forwarder = None
165 166 167 168 169
    if config.client:
        config.babel_args.append('re6stnet')
    elif config.max_clients:
        if config.pp:
            pp = [(int(port), proto) for port, proto in config.pp]
170 171 172 173
            for port, proto in pp:
                if proto in config.disable_proto:
                    sys.exit("error: conflicting options --disable-proto %s"
                             " and --pp %u %s" % (proto, port, proto))
Julien Muchembled's avatar
Julien Muchembled committed
174
        else:
175 176
            pp = [x for x in ((1194, 'udp'), (1194, 'tcp'))
                    if x[1] not in config.disable_proto]
177 178 179 180 181 182 183 184 185 186 187 188
        def ip_changed(ip):
            for family, proto_list in ((socket.AF_INET, ('tcp', 'udp')),
                                       (socket.AF_INET6, ('tcp6', 'udp6'))):
                try:
                    socket.inet_pton(family, ip)
                    break
                except socket.error:
                    pass
            else:
                family = None
            return family, [(ip, str(port), proto) for port, proto in pp
                            if not family or proto in proto_list]
189 190 191 192 193 194 195
        if config.gw_list:
          gw_list = deque(config.gw_list)
          def remote_gateway(dest):
            gw_list.rotate()
            return gw_list[0]
        else:
          remote_gateway = None
196 197 198 199 200 201 202 203 204
        if len(config.ip) > 1:
            if 'upnp' in config.ip or 'any' in config.ip:
                sys.exit("error: argument --ip can be given only once with"
                         " 'any' or 'upnp' value")
            logging.info("Multiple --ip passed: note that re6st does nothing to"
                " make sure that incoming paquets are replied via the correct"
                " gateway. So without manual network configuration, this can"
                " not be used to accept server connections from multiple"
                " gateways.")
205
        if 'upnp' in config.ip or not config.ip:
206 207 208
            logging.info('Attempting automatic configuration via UPnP...')
            try:
                from re6st.upnpigd import Forwarder
209
                forwarder = Forwarder('re6stnet openvpn server')
210 211 212 213 214 215 216
            except Exception, e:
                if config.ip:
                    raise
                logging.info("%s: assume we are not NATed", e)
            else:
                atexit.register(forwarder.clear)
                for port, proto in pp:
217 218
                    forwarder.addRule(port, proto)
                ip_changed = forwarder.checkExternalIp
219
                address = ip_changed(),
220
        elif 'any' not in config.ip:
221
            address = map(ip_changed, config.ip)
222 223 224
            ip_changed = None
        for x in pp:
            server_tunnels.setdefault('re6stnet-' + x[1], x)
225 226
    else:
        ip_changed = remote_gateway = None
227

228 229 230 231 232 233 234 235 236
    def call(cmd):
        logging.debug('%r', cmd)
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                  stderr=subprocess.PIPE)
        stdout, stderr = p.communicate()
        if p.returncode:
            raise EnvironmentError("%r failed with error %u\n%s"
                                   % (' '.join(cmd), p.returncode, stderr))
        return stdout
237 238 239
    def required(arg):
        if not getattr(config, arg):
            sys.exit("error: argument --%s is required" % arg)
240
    def ip(object, *args):
241 242 243
        args = ['ip', '-6', object, 'add'] + list(args)
        call(args)
        args[3] = 'del'
244
        cleanup.append(lambda: subprocess.call(args))
245

246
    try:
247
        subnet = network + cert.prefix
248
        my_ip = utils.ipFromBin(subnet, '1')
Julien Muchembled's avatar
Julien Muchembled committed
249 250 251 252 253 254
        my_subnet = '%s/%u' % (utils.ipFromBin(subnet), len(subnet))
        my_network = "%s/%u" % (utils.ipFromBin(network), len(network))
        os.environ['re6stnet_ip'] = my_ip
        os.environ['re6stnet_iface'] = config.main_interface
        os.environ['re6stnet_subnet'] = my_subnet
        os.environ['re6stnet_network'] = my_network
255
        my_ip += '/%s' % len(subnet)
256

257
        # Init db and tunnels
258
        tunnel_interfaces = server_tunnels.keys()
259
        timeout = 4 * config.hello
260
        cleanup = []
261
        if config.client_count and not config.client:
262
            peer_db = db.PeerDB(db_path, config.registry, cert)
263
            cleanup.append(lambda: peer_db.cacheMinimize(config.client_count))
264
            tunnel_manager = tunnel.TunnelManager(config.control_socket,
265 266
                peer_db, cert, config.openvpn_args, timeout,
                config.tunnel_refresh, config.client_count, config.iface_list,
267
                address, ip_changed, config.encrypt, remote_gateway,
268
                config.disable_proto, config.neighbour)
269
            cleanup.append(tunnel_manager.sock.close)
270
            tunnel_interfaces += tunnel_manager.new_iface_list
271
            write_pipe = tunnel_manager.write_pipe
272 273
        else:
            tunnel_manager = write_pipe = None
274

275
        try:
276
            exit.acquire()
277
            # Source address selection is defined by RFC 6724, and in most
278 279 280 281 282 283 284 285 286
            # applications, it usually works  thanks to rule 5 (prefer outgoing
            # interface). But here, it rarely applies because we use several
            # interfaces to connect to a re6st network.
            # Rule 7 is little strange because it prefers temporary addresses
            # over IP with a longer matching prefix (rule 8, which is not even
            # mandatory).
            # So only rule 6 can make the difference, i.e. prefer same label.
            # The value of the label does not matter, except that it must be
            # different from ::/0's (normally equal to 1).
287 288 289
            # XXX: This does not work with extra interfaces that already have
            #      an public IP so Babel must be changed to set a source
            #      address on routes it installs.
290
            ip('addrlabel', 'prefix', my_network, 'label', '99')
291 292
            # prepare persistent interfaces
            if config.client:
293 294 295 296 297
                address_list = [x for x in utils.parse_address(config.client)
                                  if x[2] not in config.disable_proto]
                if not address_list:
                    sys.exit("error: --disable_proto option disables"
                             " all addresses given by --client")
298
                cleanup.append(plib.client('re6stnet',
299 300
                    address_list, config.encrypt, '--ping-restart',
                    str(timeout), *config.openvpn_args).stop)
301 302 303
            elif server_tunnels:
                required('dh')
                for iface, (port, proto) in server_tunnels.iteritems():
304 305
                    cleanup.append(plib.server(iface, config.max_clients,
                        config.dh, write_pipe, port, proto, config.encrypt,
306
                        '--ping-exit', str(timeout), *config.openvpn_args).stop)
307

308
            ip('addr', my_ip, 'dev', config.main_interface)
309 310
            if_rt = ['ip', '-6', 'route', 'del',
                     'fe80::/64', 'dev', config.main_interface]
311
            if config.main_interface == 'lo':
312 313 314
                # WKRD: Removed this useless route now, since the kernel does
                #       not even remove it on exit.
                subprocess.call(if_rt)
Julien Muchembled's avatar
Julien Muchembled committed
315
            if_rt[4] = my_subnet
316 317
            cleanup.append(lambda: subprocess.call(if_rt))
            x = [my_network]
318 319 320
            if config.gateway:
                config.table = 0
            elif config.table:
321 322 323 324
                x += 'table', str(config.table)
                try:
                    ip('rule', 'from', *x)
                except EnvironmentError:
325 326 327 328 329 330 331 332 333
                    logging.error("It seems that your kernel was compiled"
                        " without support for source address based routing"
                        " (CONFIG_IPV6_SUBTREES). Consider using --table=0"
                        " option if you can't change your kernel.")
                    raise
                ip('rule', 'to', *x)
                call(if_rt)
                if_rt += x[1:]
                call(if_rt[:3] + ['add', 'proto', 'static'] + if_rt[4:])
334 335
            else:
                def check_no_default_route():
336 337 338 339 340 341 342 343
                    for route in call(('ip', '-6', 'route', 'show',
                                        'default')).splitlines():
                        if ' proto 42 ' not in route:
                            sys.exit("Detected default route (%s)"
                                " whereas you specified --table=0."
                                " Fix your configuration." % route)
                check_no_default_route()
                def check_no_default_route_thread():
344 345 346
                    try:
                        while True:
                            time.sleep(60)
347 348 349 350 351
                            try:
                                check_no_default_route()
                            except OSError, e:
                                if e.errno != errno.ENOMEM:
                                    raise
352 353 354
                    except:
                        utils.log_exception()
                    finally:
355 356
                        exit.kill_main(1)
                t = threading.Thread(target=check_no_default_route_thread)
357 358
                t.daemon = True
                t.start()
359 360 361 362 363 364 365
            ip('route', 'unreachable', *x)

            config.babel_args += config.iface_list
            cleanup.append(plib.router(subnet, config.hello, config.table,
                os.path.join(config.log, 'babeld.log'),
                os.path.join(config.state, 'babeld.state'),
                config.babel_pidfile, tunnel_interfaces,
366
                config.control_socket,
367
                *config.babel_args).stop)
Julien Muchembled's avatar
Julien Muchembled committed
368
            if config.up:
369
                exit.release()
Julien Muchembled's avatar
Julien Muchembled committed
370 371 372
                r = os.system(config.up)
                if r:
                    sys.exit(r)
373
                exit.acquire()
Julien Muchembled's avatar
Julien Muchembled committed
374
            for cmd in config.daemon or ():
375
                cleanup.insert(-1, utils.Popen(cmd, shell=True).stop)
376 377

            # main loop
378 379 380
            select_list = [forwarder.select] if forwarder else []
            if tunnel_manager:
                select_list.append(tunnel_manager.select)
381 382
                cleanup[-1:-1] = (tunnel_manager.delInterfaces,
                                  tunnel_manager.killAll)
383
            exit.release()
384 385 386
            def renew():
                raise ReexecException("Restart to renew certificate")
            select_list.append(utils.select)
387
            while True:
388
                args = {}, {}, [(next_renew, renew)]
389 390
                for s in select_list:
                    s(*args)
391
        finally:
392 393 394
            # XXX: We have a possible race condition if a signal is handled at
            #      the beginning of this clause, just before the following line.
            exit.acquire(0) # inhibit signals
395
            while cleanup:
396
                try:
397
                    cleanup.pop()()
398 399
                except:
                    pass
400
            exit.release()
401
    except sqlite3.Error:
402
        logging.exception("Restarting with empty cache")
Guillaume Bury's avatar
Guillaume Bury committed
403
        os.rename(db_path, db_path + '.bak')
404 405
    except ReexecException, e:
        logging.info(e)
406
    except Exception:
407
        utils.log_exception()
408
        sys.exit(1)
409 410 411 412
    try:
        sys.exitfunc()
    finally:
        os.execvp(sys.argv[0], sys.argv)
Guillaume Bury's avatar
Guillaume Bury committed
413 414 415

if __name__ == "__main__":
    main()