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