Abort in case of unexpected default route
[re6stnet.git] / re6stnet
1 #!/usr/bin/python
2 import atexit, errno, logging, os, select, signal
3 import sqlite3, subprocess, sys, time, threading
4 from collections import deque
5 from OpenSSL import crypto
6 from re6st import db, plib, tunnel, utils
7 from re6st.registry import RegistryClient, RENEW_PERIOD
8
9 class ReexecException(Exception):
10     pass
11
12 def getConfig():
13     parser = utils.ArgParser(fromfile_prefix_chars='@',
14         description="Resilient virtual private network application.")
15     _ = parser.add_argument
16
17     _('--ip', action='append', default=[],
18         help="IP address advertised to other nodes. Special values:\n"
19              "- upnp: redirect ports when UPnP device is found\n"
20              "- any: ask peers our IP\n"
21              " (default: like 'upnp' if miniupnpc is installed,\n"
22              "  otherwise like 'any')")
23     _('--registry', metavar='URL',
24         help="Public HTTP URL of the registry, for bootstrapping.")
25     _('-l', '--log', default='/var/log/re6stnet',
26         help="Path to the directory used for log files:\n"
27              "- re6stnet.log: log file of re6stnet itself\n"
28              "- babeld.log: log file of router\n"
29              "- <iface>.log: 1 file per spawned OpenVPN\n")
30     _('-s', '--state', default='/var/lib/re6stnet',
31         help="Path to re6stnet state directory:\n"
32              "- peers.db: cache of peer addresses\n"
33              "- babeld.state: see option -S of babeld\n")
34     _('-v', '--verbose', default=1, type=int, metavar='LEVEL',
35         help="Log level of re6stnet itself. 0 disables logging."
36              " Use SIGUSR1 to reopen log."
37              " See also --babel-verb and --verb for logs of spawned processes.")
38     _('-i', '--interface', action='append', dest='iface_list', default=[],
39         help="Extra interface for LAN discovery. Highly recommanded if there"
40              " 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.")
44     _('--up', metavar='CMD',
45         help="Shell command to run after successful initialization.")
46     _('--daemon', action='append', metavar='CMD',
47         help="Same as --up, but run in background: the command will be killed"
48              " at exit (with a TERM signal, followed by KILL 5 seconds later"
49              " if process is still alive).")
50     _('--test', metavar='EXPR',
51         help="Exit after configuration parsing. Status code is the"
52              " result of the given Python expression. For example:\n"
53              "  main_interface != 'eth0'")
54
55     _ = parser.add_argument_group('routing').add_argument
56     _('-B', dest='babel_args', metavar='ARG', action='append', default=[],
57         help="Extra arguments to forward to Babel.")
58     _('--babel-pidfile', metavar='PID', default='/var/run/re6st-babeld.pid',
59         help="Specify a file to write our process id to"
60              " (option -I of Babel).")
61     _('--hello', type=int, default=15,
62         help="Hello interval in seconds, for both wired and wireless"
63              " connections. OpenVPN ping-exit option is set to 4 times the"
64              " hello interval. It takes between 3 and 4 times the"
65              " hello interval for Babel to re-establish connection with a"
66              " node for which the direct connection has been cut.")
67     _('--table', type=int, default=42,
68         help="Use given table id. Set 0 to use the main table, if you want to"
69              " access internet via this network (in this case, make sure you"
70              " don't already have a default route). Don't use this option with"
71              " --gateway (main table is automatically used).")
72     _('--gateway', action='store_true',
73         help="Act as a gateway for this network (the default route will be"
74              " exported). Do never use it if you don't know what it means.")
75
76     _ = parser.add_argument_group('tunnelling').add_argument
77     _('-O', dest='openvpn_args', metavar='ARG', action='append', default=[],
78         help="Extra arguments to forward to both server and client OpenVPN"
79              " subprocesses. Often used to configure verbosity.")
80     _('--ovpnlog', action='store_true',
81         help="Tell each OpenVPN subprocess to log to a dedicated file.")
82     _('--encrypt', action='store_true',
83         help='Specify that tunnels should be encrypted.')
84     _('--pp', nargs=2, action='append', metavar=('PORT', 'PROTO'),
85         help="Port and protocol to be announced to other peers, ordered by"
86              " preference. For each protocol (either udp or tcp), start one"
87              " openvpn server on the first given port."
88              " (default: --pp 1194 udp --pp 1194 tcp)")
89     _('--dh',
90         help='File containing Diffie-Hellman parameters in .pem format')
91     _('--ca', required=True, help=parser._ca_help)
92     _('--cert', required=True,
93         help="Local peer's signed certificate in .pem format."
94              " Common name defines the allocated prefix in the network.")
95     _('--key', required=True,
96         help="Local peer's private key in .pem format.")
97     _('--client-count', default=10, type=int,
98         help="Number of client tunnels to set up.")
99     _('--max-clients', type=int,
100         help="Maximum number of accepted clients per OpenVPN server. (default:"
101              " client-count * 2, which actually represents the average number"
102              " of tunnels to other peers)")
103     _('--tunnel-refresh', default=300, type=int,
104         help="Interval in seconds between two tunnel refresh: the worst"
105              " tunnel is closed if the number of client tunnels has reached"
106              " its maximum number (client-count).")
107     _('--remote-gateway', action='append', dest='gw_list',
108         help="Force each tunnel to be created through one the given gateways,"
109              " in a round-robin fashion.")
110     _('--disable-proto', action='append', choices=('udp', 'tcp'), default=[],
111         help="Do never try to create tunnels using given protocols.")
112     _('--client', metavar='HOST,PORT,PROTO[;...]',
113         help="Do not run any OpenVPN server, but only 1 OpenVPN client,"
114              " with specified remotes. Any other option not required in this"
115              " mode is ignored (e.g. client-count, max-clients, etc.)")
116
117     return parser.parse_args()
118
119 def maybe_renew(path, cert, info, renew):
120     while True:
121         next_renew = utils.notAfter(cert) - RENEW_PERIOD
122         if time.time() < next_renew:
123             return cert, next_renew
124         try:
125             pem = renew()
126             if not pem or pem == crypto.dump_certificate(
127                   crypto.FILETYPE_PEM, cert):
128                 exc_info = 0
129                 break
130             cert = crypto.load_certificate(crypto.FILETYPE_PEM, pem)
131         except Exception:
132             exc_info = 1
133             break
134         new_path = path + '.new'
135         with open(new_path, 'w') as f:
136             f.write(pem)
137         os.rename(new_path, path)
138         logging.info("%s renewed until %s UTC",
139             info, time.asctime(time.gmtime(utils.notAfter(cert))))
140     logging.error("%s not renewed. Will retry tomorrow.",
141                   info, exc_info=exc_info)
142     return cert, time.time() + 86400
143
144 def exit(status):
145     exit.status = status
146     os.kill(os.getpid(), signal.SIGTERM)
147
148 def main():
149     # Get arguments
150     config = getConfig()
151     with open(config.ca) as f:
152         ca = crypto.load_certificate(crypto.FILETYPE_PEM, f.read())
153     with open(config.cert) as f:
154         cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read())
155     prefix = utils.binFromSubnet(utils.subnetFromCert(cert))
156     config.openvpn_args += (
157         '--ca', config.ca,
158         '--cert', config.cert,
159         '--key', config.key)
160     # TODO: verify certificates (should we moved to M2Crypto ?)
161
162     if config.test:
163         sys.exit(eval(config.test, None, config.__dict__))
164
165     # Set logging
166     utils.setupLog(config.verbose, os.path.join(config.log, 're6stnet.log'))
167
168     logging.trace("Environment: %r", os.environ)
169     logging.trace("Configuration: %r", config)
170     utils.makedirs(config.state)
171     db_path = os.path.join(config.state, 'peers.db')
172     if config.ovpnlog:
173         plib.ovpn_log = config.log
174
175     signal.signal(signal.SIGHUP, lambda *args: sys.exit(-1))
176     signal.signal(signal.SIGTERM, lambda *args:
177         sys.exit(getattr(exit, 'status', None)))
178
179     registry = RegistryClient(config.registry, config.key, ca)
180     cert, next_renew = maybe_renew(config.cert, cert, "Certificate",
181                                    lambda: registry.renewCertificate(prefix))
182     ca, ca_renew = maybe_renew(config.ca, ca, "CA Certificate", registry.getCa)
183     if next_renew > ca_renew:
184         next_renew = ca_renew
185     network = utils.networkFromCa(ca)
186
187     if config.max_clients is None:
188         config.max_clients = config.client_count * 2
189
190     address = []
191     server_tunnels = {}
192     if config.client:
193         config.babel_args.append('re6stnet')
194     elif config.max_clients:
195         if config.pp:
196             pp = [(int(port), proto) for port, proto in config.pp]
197         else:
198             pp = (1194, 'udp'), (1194, 'tcp')
199         ip_changed = lambda ip: [(ip, str(port), proto) for port, proto in pp]
200         if config.gw_list:
201           gw_list = deque(config.gw_list)
202           def remote_gateway(dest):
203             gw_list.rotate()
204             return gw_list[0]
205         else:
206           remote_gateway = None
207         forwarder = None
208         if len(config.ip) > 1:
209             if 'upnp' in config.ip or 'any' in config.ip:
210                 sys.exit("error: argument --ip can be given only once with"
211                          " 'any' or 'upnp' value")
212             logging.info("Multiple --ip passed: note that re6st does nothing to"
213                 " make sure that incoming paquets are replied via the correct"
214                 " gateway. So without manual network configuration, this can"
215                 " not be used to accept server connections from multiple"
216                 " gateways.")
217         if 'upnp' in config.ip or not config.ip:
218             logging.info('Attempting automatic configuration via UPnP...')
219             try:
220                 from re6st.upnpigd import Forwarder
221                 forwarder = Forwarder('re6stnet openvpn server')
222             except Exception, e:
223                 if config.ip:
224                     raise
225                 logging.info("%s: assume we are not NATed", e)
226             else:
227                 atexit.register(forwarder.clear)
228                 for port, proto in pp:
229                     forwarder.addRule(port, proto)
230                 ip_changed = forwarder.checkExternalIp
231                 address = ip_changed()
232         elif 'any' not in config.ip:
233             address = sum(map(ip_changed, config.ip), [])
234             ip_changed = None
235         for x in pp:
236             server_tunnels.setdefault('re6stnet-' + x[1], x)
237
238     def call(cmd):
239         logging.debug('%r', cmd)
240         p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
241                                   stderr=subprocess.PIPE)
242         stdout, stderr = p.communicate()
243         if p.returncode:
244             raise EnvironmentError("%r failed with error %u\n%s"
245                                    % (' '.join(cmd), p.returncode, stderr))
246         return stdout
247     def required(arg):
248         if not getattr(config, arg):
249             sys.exit("error: argument --%s is required" % arg)
250     def ip(object, *args):
251         args = ['ip', '-6', object, 'add'] + list(args)
252         call(args)
253         args[3] = 'del'
254         cleanup.append(lambda: subprocess.call(args))
255
256     try:
257         subnet = network + prefix
258         my_ip = '%s/%s' % (utils.ipFromBin(subnet, '1'), len(subnet))
259         my_subnet = '%s/%u' % (utils.ipFromBin(subnet), len(subnet))
260         my_network = "%s/%u" % (utils.ipFromBin(network), len(network))
261         os.environ['re6stnet_ip'] = my_ip
262         os.environ['re6stnet_iface'] = config.main_interface
263         os.environ['re6stnet_subnet'] = my_subnet
264         os.environ['re6stnet_network'] = my_network
265
266         # Init db and tunnels
267         tunnel_interfaces = server_tunnels.keys()
268         timeout = 4 * config.hello
269         cleanup = []
270         if config.client_count and not config.client:
271             required('registry')
272             # Create and open read_only pipe to get server events
273             r_pipe, write_pipe = os.pipe()
274             read_pipe = os.fdopen(r_pipe)
275             peer_db = db.PeerDB(db_path, registry, config.key, network, prefix)
276             tunnel_manager = tunnel.TunnelManager(write_pipe, peer_db,
277                 config.openvpn_args, timeout, config.tunnel_refresh,
278                 config.client_count, config.iface_list, network, prefix,
279                 address, ip_changed, config.encrypt, remote_gateway,
280                 config.disable_proto)
281             cleanup.append(tunnel_manager.sock.close)
282             tunnel_interfaces += tunnel_manager.new_iface_list
283         else:
284             tunnel_manager = write_pipe = None
285
286         try:
287             # Source address selection is defined by RFC 6724, and in most
288             # applications, it usually works  thanks to rule 5 (prefer outgoing
289             # interface). But here, it rarely applies because we use several
290             # interfaces to connect to a re6st network.
291             # Rule 7 is little strange because it prefers temporary addresses
292             # over IP with a longer matching prefix (rule 8, which is not even
293             # mandatory).
294             # So only rule 6 can make the difference, i.e. prefer same label.
295             # The value of the label does not matter, except that it must be
296             # different from ::/0's (normally equal to 1).
297             # XXX: This does not work with extra interfaces that already have
298             #      an public IP so Babel must be changed to set a source
299             #      address on routes it installs.
300             ip('addrlabel', 'prefix', my_network, 'label', '99')
301             # prepare persistent interfaces
302             if config.client:
303                 cleanup.append(plib.client('re6stnet',
304                     utils.parse_address(config.client),
305                     config.encrypt, '--ping-restart', str(timeout),
306                     *config.openvpn_args).stop)
307             elif server_tunnels:
308                 required('dh')
309                 for iface, (port, proto) in server_tunnels.iteritems():
310                     cleanup.append(plib.server(iface, config.max_clients,
311                         config.dh, write_pipe, port, proto, config.encrypt,
312                         '--ping-exit', str(timeout), *config.openvpn_args).stop)
313
314             ip('addr', my_ip, 'dev', config.main_interface)
315             if_rt = ['ip', '-6', 'route', 'del',
316                      'fe80::/64', 'dev', config.main_interface]
317             if config.main_interface == 'lo':
318                 # WKRD: Removed this useless route now, since the kernel does
319                 #       not even remove it on exit.
320                 subprocess.call(if_rt)
321             if_rt[4] = my_subnet
322             cleanup.append(lambda: subprocess.call(if_rt))
323             x = [my_network]
324             if config.gateway:
325                 config.table = 0
326             elif config.table:
327                 x += 'table', str(config.table)
328                 try:
329                     ip('rule', 'from', *x)
330                 except EnvironmentError:
331                     logging.error("It seems that your kernel was compiled"
332                         " without support for source address based routing"
333                         " (CONFIG_IPV6_SUBTREES). Consider using --table=0"
334                         " option if you can't change your kernel.")
335                     raise
336                 ip('rule', 'to', *x)
337                 call(if_rt)
338                 if_rt += x[1:]
339                 call(if_rt[:3] + ['add', 'proto', 'static'] + if_rt[4:])
340             else:
341                 def check_no_default_route():
342                     try:
343                         while True:
344                             for route in call(('ip', '-6', 'route', 'show',
345                                                'default')).splitlines():
346                                 if ' proto 42 ' not in route:
347                                     logging.fatal("Detected default route (%s)"
348                                         " whereas you specified --table=0."
349                                         " Fix your configuration.", route)
350                                     return
351                             time.sleep(60)
352                     except:
353                         utils.log_exception()
354                     finally:
355                         exit(1)
356                 t = threading.Thread(target=check_no_default_route)
357                 t.daemon = True
358                 t.start()
359             ip('route', 'unreachable', *x)
360
361             config.babel_args += config.iface_list
362             cleanup.append(plib.router(subnet, config.hello, config.table,
363                 os.path.join(config.log, 'babeld.log'),
364                 os.path.join(config.state, 'babeld.state'),
365                 config.babel_pidfile, tunnel_interfaces,
366                 *config.babel_args).stop)
367             if config.up:
368                 r = os.system(config.up)
369                 if r:
370                     sys.exit(r)
371             for cmd in config.daemon or ():
372                 cleanup.append(utils.Popen(cmd, shell=True).stop)
373
374             # main loop
375             if tunnel_manager is None:
376                 time.sleep(max(0, next_renew - time.time()))
377                 raise ReexecException("Restart to renew certificate")
378             cleanup += tunnel_manager.delInterfaces, tunnel_manager.killAll
379             while True:
380                 next = tunnel_manager.next_refresh
381                 if forwarder:
382                     next = min(next, forwarder.next_refresh)
383                 r = [read_pipe, tunnel_manager.sock]
384                 try:
385                     r = select.select(r, [], [], max(0, next - time.time()))[0]
386                 except select.error as e:
387                     if e.args[0] != errno.EINTR:
388                         raise
389                     continue
390                 if read_pipe in r:
391                     tunnel_manager.handleTunnelEvent(read_pipe.readline())
392                 if tunnel_manager.sock in r:
393                     tunnel_manager.handlePeerEvent()
394                 t = time.time()
395                 if t >= tunnel_manager.next_refresh:
396                     tunnel_manager.refresh()
397                     if t >= next_renew:
398                         raise ReexecException("Restart to renew certificate")
399                 if forwarder and t >= forwarder.next_refresh:
400                     forwarder.refresh()
401         finally:
402             while cleanup:
403                 try:
404                     cleanup.pop()()
405                 except:
406                     pass
407     except sqlite3.Error:
408         logging.exception("Restarting with empty cache")
409         os.rename(db_path, db_path + '.bak')
410     except ReexecException, e:
411         logging.info(e)
412     except KeyboardInterrupt:
413         return 0
414     except Exception:
415         utils.log_exception()
416         sys.exit(1)
417     try:
418         sys.exitfunc()
419     finally:
420         os.execvp(sys.argv[0], sys.argv)
421
422 if __name__ == "__main__":
423     main()