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