diff --git a/dist/tools/dhcpv6-pd_ia/README.md b/dist/tools/dhcpv6-pd_ia/README.md new file mode 100644 index 0000000000..e17bd58254 --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/README.md @@ -0,0 +1,25 @@ +# DHCPv6 PD_IA server + +This provides tooling to bootstrap a [DHCPv6] server with [prefix delegation] +support ([Kea]) to provide a prefix for the [`gnrc_border_router` example]'s +or similar applications' subnets. + +# Usage +Just run the script `dhcpv6-pd_ia.py` with sudo, e.g. + +```sh +sudo ./dhcpv6-pd_ia.py tap0 2001:db8::/32 +``` + +For more information on the arguments, have a look at the usage information of +the script + +```sh +./dhcpv6-pd_ia.py -h +``` + + +[DHCPv6]: https://tools.ietf.org/html/rfc8415 +[prefix delegation]: https://en.wikipedia.org/wiki/Prefix_delegation +[Kea]: http://kea.isc.org +[`gnrc_border_router` example]: ../../../examples/gnrc_border_router diff --git a/dist/tools/dhcpv6-pd_ia/base.py b/dist/tools/dhcpv6-pd_ia/base.py new file mode 100644 index 0000000000..96a2bb97c8 --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/base.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + + +import atexit +import os +import shutil +import subprocess +import time +import threading +import signal +import sys + +import pkg + + +__author__ = "Martine S. Lenders" +__copyright__ = "Copyright (C) 2020 Freie Universität Berlin" +__credits__ = ["Martine S. Lenders"] +__license__ = "LGPLv2.1" +__maintainer__ = "Martine S. Lenders" +__email__ = "m.lenders@fu-berlin.de" + + +# see https://refactoring.guru/design-patterns/singleton/python/example +class _SingletonMeta(type): + """ + This is a thread-safe implementation of Singleton. + """ + + _instance = None + + _lock = threading.Lock() + """ + We now have a lock object that will be used to synchronize threads + during first access to the Singleton. + """ + + def __call__(cls, *args, **kwargs): + # Now, imagine that the program has just been launched. Since + # there's no Singleton instance yet, multiple threads can + # simultaneously pass the previous conditional and reach this point + # almost at the same time. The first of them will acquire lock and + # will proceed further, while the rest will wait here. + with cls._lock: + # The first thread to acquire the lock, reaches this + # conditional, goes inside and creates the Singleton instance. + # Once it leaves the lock block, a thread that might have been + # waiting for the lock release may then enter this section. But + # since the Singleton field is already initialized, the thread + # won't create a new object. + if not cls._instance: + cls._instance = super().__call__(*args, **kwargs) + return cls._instance + + +class DHCPv6Server(metaclass=_SingletonMeta): + """ + Inspired by Daemon class + https://web.archive.org/web/20160305151936/http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/ + """ + PORT = 547 + command = None + package = None + installer = None + + def __init__(self, daemonized=False, pidfile=None, stdin=None, stdout=None, + stderr=None): + if self.command is None or self.package is None: + raise NotImplementedError("Please inherit from {} and set the " + "the static attributes `command` and " + "`packet`".format(type(self)), file=sys) + assert (not daemonized) or (pidfile is not None) + self.daemonized = daemonized + self.pidfile = pidfile + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + + @classmethod + def is_installed(cls): + return shutil.which(cls.command[0]) is not None + + def install(self): + self.installer = pkg.PackageManagerFactory.get_installer() + self.installer.install(self.package) + + def daemonize(self): + """ + do the UNIX double-fork magic, see Stevens' "Advanced + Programming in the UNIX Environment" for details (ISBN 0201563177) + http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 + """ + try: + pid = os.fork() + if pid > 0: + # exit first parent + sys.exit(0) + except OSError as e: + sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror)) + sys.exit(1) + + # decouple from parent environment + os.chdir("/") + os.setsid() + os.umask(0) + + # do second fork + try: + pid = os.fork() + if pid > 0: + # exit from second parent + sys.exit(0) + except OSError as e: + sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror)) + sys.exit(1) + + # redirect standard file descriptors + if self.stdin: + si = open(self.stdin, 'r') + os.dup2(si.fileno(), sys.stdin.fileno()) + if self.stdout: + sys.stdout.flush() + so = open(self.stdout, 'a+') + os.dup2(so.fileno(), sys.stdout.fileno()) + if self.stderr: + sys.stderr.flush() + se = open(self.stderr, 'a+', 0) + os.dup2(se.fileno(), sys.stderr.fileno()) + + atexit.register(self.delpid) + # write pidfile + with open(self.pidfile, "w+") as f: + f.write("{}\n".format(os.getpid())) + + def delpid(self): + os.remove(self.pidfile) + + def stop(self): + """ + Stop the daemon + """ + # Get the pid from the pidfile + try: + pf = open(self.pidfile, 'r') + pid = int(pf.read().strip()) + pf.close() + except IOError: + pid = None + if not pid: + message = "pidfile %s does not exist. Daemon not running?\n" + sys.stderr.write(message % self.pidfile) + return # not an error in a restart + + # Try killing the daemon process + try: + while 1: + os.kill(pid, signal.SIGTERM) + time.sleep(0.1) + except OSError as err: + err = str(err) + if err.find("No such process") > 0: + if os.path.exists(self.pidfile): + os.remove(self.pidfile) + else: + print(str(err), file=sys.stderr) + sys.exit(1) + + def pre_run(self): + # may be overridden by implementation to do things before running + # the daemon or script + pass + + def run(self): + if not self.is_installed(): + self.install() + if self.daemonized: + """ + Start the daemon + """ + # Check for a pidfile to see if the daemon already runs + try: + pf = open(self.pidfile, 'r') + pid = int(pf.read().strip()) + pf.close() + except (IOError, ValueError): + pid = None + if pid: + message = "pidfile %s already exist. Daemon already running?\n" + sys.stderr.write(message % self.pidfile) + sys.exit(1) + # Start the daemon + self.daemonize() + self.pre_run() + subprocess.run(self.command) diff --git a/dist/tools/dhcpv6-pd_ia/dhcpv6-pd_ia.py b/dist/tools/dhcpv6-pd_ia/dhcpv6-pd_ia.py new file mode 100755 index 0000000000..f463cf7a4c --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/dhcpv6-pd_ia.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + +import argparse +import os + +import kea +import util + +__author__ = "Martine S. Lenders" +__copyright__ = "Copyright (C) 2020 Freie Universität Berlin" +__credits__ = ["Martine S. Lenders"] +__license__ = "LGPLv2.1" +__version__ = "0.0.1" +__maintainer__ = "Martine S. Lenders" +__email__ = "m.lenders@fu-berlin.de" +__status__ = "Experimental" + + +DEFAULT_NEXT_HOP = "fe80::2" +DEFAULT_DELEGATED_LEN = 64 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-d", "--daemonized", action="store_true", + help="Run server in background") + parser.add_argument("-p", "--pidfile", nargs="?", + help="PID file for the server. Required with -d.") + parser.add_argument( + "-n", "--next-hop", default=DEFAULT_NEXT_HOP, nargs="?", + help="Next hop address for application (default: fe80::2)" + ) + parser.add_argument( + "-g", "--delegated-len", default=DEFAULT_DELEGATED_LEN, nargs="?", + type=int, + help="The prefix length delegated by the DHCPv6 server. " + "Must be greater or equal to the prefix length of the subnet. " + "This may differ from the prefix length provided in subnet more " + "to understand as a template from which to generate the " + "delegated prefixes from. " + "(default: 64)" + ) + parser.add_argument( + "interface", help="Interface to bind DHCPv6 server to" + ) + parser.add_argument( + "subnet", type=util.split_prefix, + help="Subnet to delegate (must have format /)" + ) + args = parser.parse_args() + if "SUDO_USER" not in os.environ: + raise PermissionError("Must be run with sudo") + if args.delegated_len < args.subnet[1]: + raise ValueError("delegated_len {} is lesser than prefix length {}" + .format(args.delegated_len, args.subnet[1])) + config = kea.KeaConfig(args.interface, args.subnet[0], args.subnet[1], + args.delegated_len) + server = kea.KeaServer(config, args.next_hop, daemonized=args.daemonized, + pidfile=args.pidfile) + server.run() + + +if __name__ == "__main__": + main() diff --git a/dist/tools/dhcpv6-pd_ia/kea.py b/dist/tools/dhcpv6-pd_ia/kea.py new file mode 100644 index 0000000000..bd4bd97e0f --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/kea.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + +import json +import os +import tempfile +import time + +import base + + +__author__ = "Martine S. Lenders" +__copyright__ = "Copyright (C) 2020 Freie Universität Berlin" +__credits__ = ["Martine S. Lenders"] +__license__ = "LGPLv2.1" +__maintainer__ = "Martine S. Lenders" +__email__ = "m.lenders@fu-berlin.de" + + +class KeaConfig(object): + def __init__(self, interface, prefix, prefix_len, delegated_len=None, + valid_lifetime=40000, preferred_lifetime=30000, + renew_timer=10000, rebind_timer=20000): + if not prefix.endswith("::"): + raise ValueError("prefix must end with '::'") + if int(prefix_len) < 1 or int(prefix_len) > 128: + raise ValueError("prefix_len must be between 1 and 128") + if int(valid_lifetime) <= 0: + raise ValueError("valid_lifetime must be greater than 0") + if int(preferred_lifetime) <= 0: + raise ValueError("preferred_lifetime must be greater than 0") + if int(renew_timer) <= 0: + raise ValueError("renew_timer must be greater than 0") + if int(rebind_timer) <= 0: + raise ValueError("rebind_timer must be greater than 0") + if delegated_len is None: + delegated_len = prefix_len + self._config_dict = { + "Dhcp6": { + "interfaces-config": { + "interfaces": [interface] + }, + "lease-database": { + "type": "memfile" + }, + "valid-lifetime": int(valid_lifetime), + "preferred-lifetime": int(preferred_lifetime), + "renew-timer": int(renew_timer), + "rebind-timer": int(rebind_timer), + "subnet6": [{ + "interface": interface, + "subnet": "{}/{}".format(prefix, prefix_len), + "pd-pools": [{ + "prefix": prefix, + "prefix-len": prefix_len, + "delegated-len": delegated_len + }], + }], + }, + } + self.config_file = None + + def __del__(self): + if self.config_file is not None: + self.config_file.close() + + def _dump_json(self): + json.dump(self._config_dict, self.config_file) + self.config_file.flush() + + def __str__(self): + if self.config_file is None: + self.config_file = tempfile.NamedTemporaryFile(mode="w") + self._dump_json() + return self.config_file.name + + @property + def interface(self): + return self._config_dict["Dhcp6"]["interfaces-config"]["interfaces"][0] + + +class KeaServer(base.DHCPv6Server): + command = ["kea-dhcp6", "-c"] + package = { + "generic": {"name": "kea-dhcp6", "url": "https://kea.isc.org/"}, + "Debian": {"name": "kea-dhcp6-server"}, + "Arch": {"name": "kea"}, + } + + def __init__(self, config, next_hop="fe80::2", *args, **kwargs): + super().__init__(*args, **kwargs) + self.next_hop = next_hop + self.config = config + + def pre_run(self): + # create config file in daemon so it is not automatically deleted + self.command.append(str(self.config)) + if self.daemonized: + # need to wait for interface to connect before we can run server + time.sleep(2) + + def run(self): + if not self.is_installed(): + self.install() + if self.installer.os in ["Arch"] and \ + not os.path.exists("/var/run/kea/"): + # workaround: Arch does not create that directory on first + # install + os.makedirs("/var/run/kea/") + super().run() diff --git a/dist/tools/dhcpv6-pd_ia/pkg/__init__.py b/dist/tools/dhcpv6-pd_ia/pkg/__init__.py new file mode 100644 index 0000000000..cfb69d8726 --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/pkg/__init__.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + + +import platform +import re +import os + +from .apt import Apt +from .pacman import PacMan +from .base import AskToInstall + +__author__ = "Martine S. Lenders" +__copyright__ = "Copyright (C) 2020 Freie Universität Berlin" +__credits__ = ["Martine S. Lenders"] +__license__ = "LGPLv2.1" +__maintainer__ = "Martine S. Lenders" +__email__ = "m.lenders@fu-berlin.de" + + +class PackageManagerFactory(object): + @staticmethod + def _get_linux_distro(): + if hasattr(platform, "linux_distribution"): + return platform.linux_distribution()[0] + elif os.path.exists("/etc/os-release"): + with open("/etc/os-release") as f: + for line in f: + m = re.match(r"^NAME=\"(.+)\"$", line) + if m is not None: + return m.group(1) + return None + + @classmethod + def get_installer(cls): + system = platform.system() + if system == "Linux": + system = cls._get_linux_distro() + if system in ["Debian", "Ubuntu"]: + return Apt("Debian") + if system in ["Arch Linux"]: + return PacMan("Arch") + else: + return AskToInstall() diff --git a/dist/tools/dhcpv6-pd_ia/pkg/apt.py b/dist/tools/dhcpv6-pd_ia/pkg/apt.py new file mode 100644 index 0000000000..1f8dcc52f7 --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/pkg/apt.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + +import subprocess + +from .base import Installer + +__author__ = "Martine S. Lenders" +__copyright__ = "Copyright (C) 2020 Freie Universität Berlin" +__credits__ = ["Martine S. Lenders"] +__license__ = "LGPLv2.1" +__maintainer__ = "Martine S. Lenders" +__email__ = "m.lenders@fu-berlin.de" + + +class Apt(Installer): + def _install(self, package): + subprocess.run(["apt-get", "-y", "install", + package[self.os]["name"]]) diff --git a/dist/tools/dhcpv6-pd_ia/pkg/base.py b/dist/tools/dhcpv6-pd_ia/pkg/base.py new file mode 100644 index 0000000000..bd85ec1435 --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/pkg/base.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + +import abc +import sys + + +__author__ = "Martine S. Lenders" +__copyright__ = "Copyright (C) 2020 Freie Universität Berlin" +__credits__ = ["Martine S. Lenders"] +__license__ = "LGPLv2.1" +__maintainer__ = "Martine S. Lenders" +__email__ = "m.lenders@fu-berlin.de" + + +class Installer(abc.ABC): + def __init__(self, os): + self.os = os + + @abc.abstractmethod + def _install(self, package): + """ + Executes the install command + """ + pass + + def install(self, package): + """ + Executes the install command, but asks the user before-hand if it is + okay to do so. + """ + if self._ask(package): + self._install(package) + + @staticmethod + def _ask(package): + valid = {"yes": True, "y": True, "ye": True, + "no": False, "n": False} + while True: + sys.stdout.write("Install package {}? [Y/n] " + .format(package["generic"]["name"])) + sys.stdout.flush() + choice = input().lower() + if choice == '': + return True + elif choice in valid: + return valid[choice] + else: + raise ValueError( + "Please respond with 'yes' or 'no' (or 'y' or 'n').", + ) + + +class AskToInstall(Installer): + def install(self, package): + print("Please install {name} ({url})".format(**package["generic"]), + file=sys.stderr) diff --git a/dist/tools/dhcpv6-pd_ia/pkg/pacman.py b/dist/tools/dhcpv6-pd_ia/pkg/pacman.py new file mode 100644 index 0000000000..1d21580d30 --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/pkg/pacman.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + +import subprocess + +from .base import Installer + +__author__ = "Martine S. Lenders" +__copyright__ = "Copyright (C) 2020 Freie Universität Berlin" +__credits__ = ["Martine S. Lenders"] +__license__ = "LGPLv2.1" +__maintainer__ = "Martine S. Lenders" +__email__ = "m.lenders@fu-berlin.de" + + +class PacMan(Installer): + def _install(self, package): + subprocess.run(["pacman", "--noconfirm", "-S", + package[self.os]["name"]]) diff --git a/dist/tools/dhcpv6-pd_ia/util.py b/dist/tools/dhcpv6-pd_ia/util.py new file mode 100644 index 0000000000..f639db8e3e --- /dev/null +++ b/dist/tools/dhcpv6-pd_ia/util.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright (C) 2020 Freie Universität Berlin +# +# This file is subject to the terms and conditions of the GNU Lesser +# General Public License v2.1. See the file LICENSE in the top level +# directory for more details. + + +def split_prefix(route): + comp = route.split("/") + return comp[0], int(comp[1])