#!/usr/bin/env python3 import argparse import configparser import ipaddress import logging import logging.handlers import os import socket import sys ## import requests import requests.auth from pyroute2 import IPRoute try: # https://www.freedesktop.org/software/systemd/python-systemd/journal.html#journalhandler-class from systemd import journal _has_journald = True except ImportError: _has_journald = False # TODO: add checking to see if we're already configured. # https://wiki.archlinux.org/index.php/IPv6_tunnel_broker_setup # https://forums.he.net/index.php?topic=3153.0 # https://gist.github.com/pklaus/960672 logfile = '/var/log/tunnelbroker_manager.log' # Prep the log file. logfile = os.path.abspath(os.path.expanduser(logfile)) os.makedirs(os.path.dirname(logfile), exist_ok = True, mode = 0o0700) if not os.path.isfile(logfile): with open(logfile, 'w') as fh: fh.write('') os.chmod(logfile, 0o0600) # And set up logging. _cfg_args = {'handlers': [], 'level': logging.DEBUG} if _has_journald: # There were some weird changes somewhere along the line. try: # But it's *probably* this one. h = journal.JournalHandler() except AttributeError: h = journal.JournaldLogHandler() # Systemd includes times, so we don't need to. h.setFormatter(logging.Formatter(style = '{', fmt = ('{name}:{levelname}:{name}:{filename}:' '{funcName}:{lineno}: {message}'))) _cfg_args['handlers'].append(h) h = logging.handlers.RotatingFileHandler(logfile, encoding = 'utf8', # Disable rotating for now. # maxBytes = 50000000000, # backupCount = 30 ) h.setFormatter(logging.Formatter(style = '{', fmt = ('{asctime}:' '{levelname}:{name}:{filename}:' '{funcName}:{lineno}: {message}'))) _cfg_args['handlers'].append(h) logging.basicConfig(**_cfg_args) logger = logging.getLogger('HE Tunnelbroker Manager') logger.info('Logging initialized.') class TunnelBroker(object): ipget_url = 'https://ipv4.clientinfo.square-r00t.net/' ipget_params = {'raw': '1'} api_base = 'https://ipv4.tunnelbroker.net/nic/update' def __init__(self, conf, tun_id = None, wan_ip = True, update = True, *args, **kwargs): self.my_ip = None self.user = None self.update_key = None self.ipr = None self.iface_idx = None self.wan = wan_ip self.force_update = update self.conf_file = os.path.abspath(os.path.expanduser(conf)) logger.debug('Using config path: {0}'.format(self.conf_file)) self._conf = configparser.ConfigParser(allow_no_value = True, interpolation = configparser.ExtendedInterpolation()) try: self._conf.read(self.conf_file) logger.debug('Read in configuration successfully.') except Exception as e: logger.error('Failed reading configuration: {0}'.format(e)) raise e if len(self._conf.sections()) < 1: logger.error('Config file has no sections/tunnels defined.') raise RuntimeError('Config file has no sections/tunnels defined') self.tun_id = tun_id if self.tun_id and self.tun_id not in self._conf.sections(): logger.error(('The tun_id {0} is not a valid tunnel ID. ' 'Valid tunnels are: {1}').format(self.tun_id, ', '.join(self._conf.sections()))) raise ValueError('tun_id not a valid tunnel ID') elif not self.tun_id: self.tun_id = self._conf.sections()[0] logger.debug('Automatically set tun_id to {0}'.format(self.tun_id)) self.cfg = self._conf[self.tun_id] self.server = ipaddress.ip_address(self.cfg['server']) logger.debug('Set server IP to {0}.'.format(str(self.server))) self.addrs = [ipaddress.ip_network(ip.strip()) for ip in self.cfg['allocations'].split(',')] logger.debug('Using address allocations: {0}'.format(', '.join([str(ip) for ip in self.addrs]))) for k in ('user', 'update_key'): setattr(self, k, self.cfg[k]) # Don't log creds, even in debug. # logger.debug('Set {0} to {1}'.format(k, getattr(self, k))) self.iface_name = 'he-{0}'.format(self.tun_id) def _get_my_ip(self): if self.wan: logger.debug('WAN IP tunneling enabled; fetching WAN IP.') req = requests.get(self.ipget_url, params = self.ipget_params) if not req.ok: logger.error('Could not fetch self IP. Request returned {0}.'.format(req.status_code)) raise RuntimeError('Could not fetch self IP') self.my_ip = ipaddress.ip_address(req.json()['ip']) logger.debug('Set my_ip to {0}.'.format(str(self.my_ip))) else: logger.debug('WAN IP tunneling disabled; fetching LAN IP.') if not self.ipr: self.ipr = IPRoute() _defrt = self.ipr.get_default_routes(family = socket.AF_INET) if len(_defrt) != 1: # This (probably) WILL fail on multipath systems. logger.error('Could not determine default route. Does this machine have a single default route?') raise RuntimeError('Could not determine default IPv4 route') self.my_ip = ipaddress.ip_address(_defrt[0]['attrs']['RTA_PREFSRC']) logger.debug('Set my_ip to {0}.'.format(str(self.my_ip))) return(None) def start(self): if self.force_update: logger.debug('IP update forced; updating.') self._get_my_ip() self.update() if not self.ipr: self.ipr = IPRoute() try: self.ipr.link('add', ifname = self.iface_name, kind = 'sit', sit_local = str(self.my_ip), sit_remote = str(self.server), sit_ttl = 255) logger.debug('Added link {0} successfully.'.format(self.iface_name)) except Exception as e: logger.error('Could not create link for link {0} ' '(maybe it already exists?): {1}'.format(self.iface_name, e)) raise e try: self.iface_idx = self.ipr.link_lookup(ifname = self.iface_name)[0] logger.debug('Found link {0} at index {1}.'.format(self.iface_name, self.iface_idx)) except Exception as e: logger.error('Could not set iface_idx for iface name {0}: {1}'.format(self.iface_name, e)) raise e try: self.ipr.link('set', index = self.iface_idx, state = 'up', mtu = 1480) logger.debug('Set link {0} status to UP.'.format(self.iface_name)) except Exception as e: logger.error(('Could not bring up link for iface name {0} at index {1}: ' '{2}').format(self.iface_name, self.iface_idx, e)) raise e for a in self.addrs: try: self.ipr.addr('add', index = self.iface_idx, address = str(a.network_address), mask = a.prefixlen, family = socket.AF_INET6) logger.debug('Added address {0} to link {1}.'.format(str(a), self.iface_name)) except Exception as e: logger.error(('Could not add address {0} on link {1}: ' '{2}').format(str(a), self.iface_name, e)) raise e try: self.ipr.route('add', dst = 'default', oif = self.iface_idx, family = socket.AF_INET6) logger.debug('Added default route for link {0}.'.format(self.iface_name)) except Exception as e: logger.error(('Could not add default IPv6 route on link {0}: {1}').format(self.iface_name, e)) raise e self.ipr.close() return(None) def stop(self): if not self.ipr: self.ipr = IPRoute() try: self.iface_idx = self.ipr.link_lookup(ifname = self.iface_name)[0] logger.debug('Found link {0} at index {1}.'.format(self.iface_name, self.iface_idx)) except Exception as e: logger.error('Could not set iface_idx for link {0}: {1}'.format(self.iface_name, e)) raise e try: self.ipr.link('set', index = self.iface_idx, state = 'down') except Exception as e: logger.error('Could not bring down link {0}: {1} (continuing anyways)'.format(self.iface_name, e)) try: self.ipr.route('del', dst = 'default', oif = self.iface_idx, family = socket.AF_INET6) except Exception as e: logger.error(('Could not remove default IPv6 route on link {0}: ' '{1} (continuing anyways)').format(self.iface_name, e)) try: self.ipr.link('del', index = self.iface_idx) logger.debug('Deleted link {0}.'.format(self.iface_name)) except Exception as e: logger.error('Could not delete link {0}: {1}'.format(self.iface_name, e)) raise e self.ipr.close() return(None) def update(self, oneshot = False): self._get_my_ip() auth_handler = requests.auth.HTTPBasicAuth(self.user, self.update_key) logger.debug('Set auth handler.') logger.debug('Requesting IP update at provider.') req = requests.get(self.api_base, params = {'hostname': self.tun_id, 'myip': self.my_ip}, auth = auth_handler) if not req.ok: logger.error('Could not update IP at provider. Request returned {0}.'.format(req.status_code)) raise RuntimeError('Could not update client IP in tunnel') status = req.content.decode('utf-8').split()[0].strip() if status.lower() not in ('good', 'nochg'): logger.error('Returned following failure message: {0}'.format(req.content.decode('utf-8'))) raise RuntimeError('Client IP update returned failure') if self.ipr and oneshot: self.ipr.close() self.ipr = None return(None) def parseArgs(): args = argparse.ArgumentParser(description = ('Dynamically update and enable/disable ' 'Hurricane Electric Tunnelbroker')) args.add_argument('-i', '--no-wan-ip', dest = 'wan_ip', action = 'store_false', help = ('If specified, use the RFC1918 IP address assigned to this machine instead of the WAN ' 'IP (necessary if this machine is behind NAT)')) args.add_argument('-c', '--config', dest = 'conf', default = '~/.config/he_tunnelbroker.ini', help = ('The path to the config. ' 'Default: ~/.config/he_tunnelbroker.ini')) args.add_argument('-t', '--tunnel-id', dest = 'tun_id', help = ('The tunnel profile ID/name to use in -c/--config. ' 'Default is to use the first one found.')) args.add_argument('-u', '--no-update', dest = 'update', action = 'store_false', help = ('If specified, do not perform the automatic update for start operations. Has no effect ' 'for other operations')) args.add_argument('oper', metavar = 'OPERATION', choices = ('update', 'start', 'stop'), help = ('The operation to perform ("start", "stop", or "update"). "update" is performed ' 'automatically by "start", but otherwise will just update the IPv4 address on record ' 'at tunnelbroker')) return(args) def main(): args = parseArgs().parse_args() logger.debug('Invoked with args: {0}'.format(vars(args))) tb = TunnelBroker(**vars(args)) if args.oper == 'start': tb.start() elif args.oper == 'stop': tb.stop() elif args.oper == 'update': tb.update(oneshot = True) return(None) if __name__ == '__main__': main()