diff --git a/.gitignore b/.gitignore index f8f01be..38b38ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ +/bin/* +!/bin/.gitkeep /plugin/ -__pycache__/ .vscode/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e4eaf5b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3-alpine - -COPY requirements.txt /opt/ -RUN apk --no-cache add gcc musl-dev && \ - pip install -r /opt/requirements.txt && \ - apk --no-cache del gcc musl-dev - -RUN mkdir -p /opt/plugin /run/docker/plugins /var/run/docker/netns -COPY net-dhcp/ /opt/plugin/net_dhcp - -WORKDIR /opt/plugin -ENTRYPOINT ["python", "-m", "net_dhcp"] diff --git a/Makefile b/Makefile index 9d7ec2c..b43cc1f 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,39 @@ PLUGIN_NAME = devplayer0/net-dhcp -PLUGIN_TAG ?= latest +PLUGIN_TAG ?= golang -all: clean build rootfs create enable +BINARY = bin/net-dhcp +PLUGIN_DIR = plugin + +.PHONY: all clean disable + +all: create enable + +$(BINARY): cmd/net-dhcp/main.go + CGO_ENABLED=0 go build -o $@ ./cmd/net-dhcp + +debug: $(BINARY) + sudo $< -log debug + +plugin: $(BINARY) config.json + mkdir -p $@/rootfs/run/docker/plugins + cp $(BINARY) $@/rootfs/ + cp config.json $@/ + +create: plugin + docker plugin rm -f ${PLUGIN_NAME}:${PLUGIN_TAG} || true + docker plugin create ${PLUGIN_NAME}:${PLUGIN_TAG} $< + +enable: plugin + docker plugin enable ${PLUGIN_NAME}:${PLUGIN_TAG} +disable: + docker plugin disable ${PLUGIN_NAME}:${PLUGIN_TAG} + +pdebug: create enable + sudo sh -c 'tail -f /var/lib/docker/plugins/*/rootfs/net-dhcp.log' + +push: plugin + docker plugin push ${PLUGIN_NAME}:${PLUGIN_TAG} clean: - @echo "### rm ./plugin" - @rm -rf ./plugin - -build: - @echo "### docker build: rootfs image with net-dhcp" - @docker build -t ${PLUGIN_NAME}:rootfs . - -rootfs: - @echo "### create rootfs directory in ./plugin/rootfs" - @mkdir -p ./plugin/rootfs - @docker create --name tmp ${PLUGIN_NAME}:rootfs - @docker export tmp | tar -x -C ./plugin/rootfs - @echo "### copy config.json to ./plugin/" - @cp config.json ./plugin/ - @docker rm -vf tmp - -create: - @echo "### remove existing plugin ${PLUGIN_NAME}:${PLUGIN_TAG} if exists" - @docker plugin rm -f ${PLUGIN_NAME}:${PLUGIN_TAG} || true - @echo "### create new plugin ${PLUGIN_NAME}:${PLUGIN_TAG} from ./plugin" - @docker plugin create ${PLUGIN_NAME}:${PLUGIN_TAG} ./plugin - -debug: - @docker run --rm -ti --cap-add CAP_SYS_ADMIN --network host --volume /run/docker/plugins:/run/docker/plugins \ - --volume /run/docker.sock:/run/docker.sock --volume /var/run/docker/netns:/var/run/docker/netns \ - ${PLUGIN_NAME}:rootfs - -enable: - @echo "### enable plugin ${PLUGIN_NAME}:${PLUGIN_TAG}" - @docker plugin enable ${PLUGIN_NAME}:${PLUGIN_TAG} - -push: - @echo "### push plugin ${PLUGIN_NAME}:${PLUGIN_TAG}" - @docker plugin push ${PLUGIN_NAME}:${PLUGIN_TAG} + -rm -rf ./plugin + -rm - bin/* diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/net-dhcp/main.go b/cmd/net-dhcp/main.go new file mode 100644 index 0000000..ac25282 --- /dev/null +++ b/cmd/net-dhcp/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "os" + "os/signal" + + "github.com/devplayer0/docker-net-dhcp/pkg/plugin" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +var ( + logLevel = flag.String("log", "info", "log level") + logFile = flag.String("logfile", "", "log file") + bindSock = flag.String("sock", "/run/docker/plugins/net-dhcp.sock", "bind unix socket") +) + +func main() { + flag.Parse() + + level, err := log.ParseLevel(*logLevel) + if err != nil { + log.WithError(err).Fatal("Failed to parse log level") + } + log.SetLevel(level) + + if *logFile != "" { + f, err := os.OpenFile(*logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + log.WithError(err).Fatal("Failed to open log file for writing") + return + } + defer f.Close() + + log.StandardLogger().Out = f + } + + p := plugin.NewPlugin() + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, unix.SIGINT, unix.SIGTERM) + + go func() { + log.Info("Starting server...") + if err := p.Start(*bindSock); err != nil { + log.WithError(err).Fatal("Failed to start server") + } + }() + + <-sigs + log.Info("Shutting down...") + p.Stop() +} diff --git a/config.json b/config.json index 57f41db..4b93931 100644 --- a/config.json +++ b/config.json @@ -6,8 +6,8 @@ "docker.networkdriver/1.0" ] }, - "entrypoint": [ "python", "-m", "net_dhcp" ], - "workdir": "/opt/plugin", + "entrypoint": ["/net-dhcp", "-logfile", "/net-dhcp.log"], + "workdir": "/", "network": { "type": "host" }, diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..80a0868 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/devplayer0/docker-net-dhcp + +go 1.14 + +require ( + github.com/sirupsen/logrus v1.6.0 + golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8699fdb --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/containous/traefik v1.7.24 h1:iFkoJBpQUQh1URdblBjbh32Wav8Ctl/WjLtAtvBzHis= +github.com/containous/traefik v1.7.24/go.mod h1:epDRqge3JzKOhlSWzOpNYEEKXmM6yfN5tPzDGKk3ljo= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/net-dhcp/__init__.py b/net-dhcp/__init__.py deleted file mode 100644 index dbee061..0000000 --- a/net-dhcp/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging - -from flask import Flask, jsonify - -class NetDhcpError(Exception): - def __init__(self, status, *args): - Exception.__init__(self, *args) - self.status = status - -app = Flask(__name__) - -from . import network - -logger = logging.getLogger('gunicorn.error') - -@app.errorhandler(404) -def err_not_found(_e): - return jsonify({'Err': 'API not found'}), 404 - -@app.errorhandler(Exception) -def err(e): - logger.exception(e) - return jsonify({'Err': str(e)}), 500 diff --git a/net-dhcp/__main__.py b/net-dhcp/__main__.py deleted file mode 100644 index afda870..0000000 --- a/net-dhcp/__main__.py +++ /dev/null @@ -1,14 +0,0 @@ -import logging -import socketserver -from werkzeug.serving import run_simple -from . import app - -fh = logging.FileHandler('/var/log/net-dhcp.log') -fh.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) - -logger = logging.getLogger('net-dhcp') -logger.setLevel(logging.DEBUG) -logger.addHandler(fh) - -socketserver.TCPServer.allow_reuse_address = True -run_simple('unix:///run/docker/plugins/net-dhcp.sock', 0, app) diff --git a/net-dhcp/network.py b/net-dhcp/network.py deleted file mode 100644 index 2926ec5..0000000 --- a/net-dhcp/network.py +++ /dev/null @@ -1,394 +0,0 @@ -import itertools -import ipaddress -import logging -import atexit -import socket -import time -import threading -import subprocess - -import pyroute2 -from pyroute2.netlink.rtnl import rtypes -import docker -from flask import request, jsonify - -from . import NetDhcpError, udhcpc, app - -OPTS_KEY = 'com.docker.network.generic' -OPT_BRIDGE = 'bridge' -OPT_IPV6 = 'ipv6' - -logger = logging.getLogger('net-dhcp') - -ndb = pyroute2.NDB() -@atexit.register -def close_ndb(): - ndb.close() - -client = docker.from_env() -@atexit.register -def close_docker(): - client.close() - -gateway_hints = {} -container_dhcp_clients = {} -@atexit.register -def cleanup_dhcp(): - for endpoint, dhcp in container_dhcp_clients.items(): - logger.warning('cleaning up orphaned container DHCP client (endpoint "%s")', endpoint) - dhcp.stop() - -def veth_pair(e): - return f'dh-{e[:12]}', f'{e[:12]}-dh' - -def iface_addrs(iface): - return list(map(lambda a: ipaddress.ip_interface((a['address'], a['prefixlen'])), iface.ipaddr)) -def iface_nets(iface): - return list(map(lambda n: n.network, iface_addrs(iface))) - -def get_bridges(): - reserved_nets = set(map(ipaddress.ip_network, map(lambda c: c['Subnet'], \ - itertools.chain.from_iterable(map(lambda i: i['Config'], filter(lambda i: i['Driver'] != 'net-dhcp', \ - map(lambda n: n.attrs['IPAM'], client.networks.list()))))))) - - return dict(map(lambda i: (i['ifname'], i), filter(lambda i: i['kind'] == 'bridge' and not \ - set(iface_nets(i)).intersection(reserved_nets), map(lambda i: ndb.interfaces[i.ifname], ndb.interfaces)))) - -def net_bridge(n): - return ndb.interfaces[client.networks.get(n).attrs['Options'][OPT_BRIDGE]] -def ipv6_enabled(n): - options = client.networks.get(n).attrs['Options'] - return OPT_IPV6 in options and options[OPT_IPV6] == 'true' - -def endpoint_container_iface(n, e): - for cid, info in client.networks.get(n).attrs['Containers'].items(): - if info['EndpointID'] == e: - container = client.containers.get(cid) - netns = f'/proc/{container.attrs["State"]["Pid"]}/ns/net' - - with pyroute2.NetNS(netns) as rtnl: - for link in rtnl.get_links(): - attrs = dict(link['attrs']) - if attrs['IFLA_ADDRESS'] == info['MacAddress']: - return { - 'netns': netns, - 'ifname': attrs['IFLA_IFNAME'], - 'address': attrs['IFLA_ADDRESS'] - } - break - return None -def await_endpoint_container_iface(n, e, timeout=5): - start = time.time() - iface = None - while time.time() - start < timeout: - try: - iface = endpoint_container_iface(n, e) - except docker.errors.NotFound: - time.sleep(0.5) - if not iface: - raise NetDhcpError('Timed out waiting for container to become availabile') - return iface - -def endpoint_container_hostname(n, e): - for cid, info in client.networks.get(n).attrs['Containers'].items(): - if info['EndpointID'] == e: - return client.containers.get(cid).attrs['Config']['Hostname'] - return None - -@app.route('/NetworkDriver.GetCapabilities', methods=['POST']) -def net_get_capabilities(): - return jsonify({ - 'Scope': 'local', - 'ConnectivityScope': 'global' - }) - -@app.route('/NetworkDriver.CreateNetwork', methods=['POST']) -def create_net(): - req = request.get_json(force=True) - for data in req['IPv4Data']: - if data['AddressSpace'] != 'null' or data['Pool'] != '0.0.0.0/0': - return jsonify({'Err': 'Only the null IPAM driver is supported'}), 400 - - options = req['Options'][OPTS_KEY] - if OPT_BRIDGE not in options: - return jsonify({'Err': 'No bridge provided'}), 400 - # We have to use a custom "enable IPv6" option because Docker's null IPAM driver doesn't support IPv6 and a plugin - # IPAM driver isn't allowed to return an empty address - if OPT_IPV6 in options and options[OPT_IPV6] not in ('', 'true', 'false'): - return jsonify({'Err': 'Invalid boolean value for ipv6'}), 400 - - desired = options[OPT_BRIDGE] - bridges = get_bridges() - if desired not in bridges: - return jsonify({'Err': f'Bridge "{desired}" not found (or the specified bridge is already used by Docker)'}), 400 - - logger.info('Creating network "%s" (using bridge "%s")', req['NetworkID'], desired) - return jsonify({}) - -@app.route('/NetworkDriver.DeleteNetwork', methods=['POST']) -def delete_net(): - return jsonify({}) - -@app.route('/NetworkDriver.CreateEndpoint', methods=['POST']) -def create_endpoint(): - req = request.get_json(force=True) - network_id = req['NetworkID'] - endpoint_id = req['EndpointID'] - req_iface = req['Interface'] - - bridge = net_bridge(network_id) - bridge_addrs = iface_addrs(bridge) - - if_host, if_container = veth_pair(endpoint_id) - logger.info('creating veth pair %s <=> %s', if_host, if_container) - if_host = (ndb.interfaces.create(ifname=if_host, kind='veth', peer=if_container) - .set('state', 'up') - .commit()) - - try: - start = time.time() - while isinstance(if_container, str) and time.time() - start < 10: - try: - if_container = (ndb.interfaces[if_container] - .set('state', 'up') - .commit()) - except KeyError: - time.sleep(0.5) - if isinstance(if_container, str): - raise NetDhcpError(f'timed out waiting for {if_container} to appear in host') - - (bridge - .add_port(if_host) - .commit()) - - res_iface = { - 'MacAddress': '', - 'Address': '', - 'AddressIPv6': '' - } - - if 'MacAddress' in req_iface and req_iface['MacAddress']: - (if_container - .set('address', req_iface['MacAddress']) - .commit()) - else: - res_iface['MacAddress'] = if_container['address'] - - def try_addr(type_): - addr = None - k = 'AddressIPv6' if type_ == 'v6' else 'Address' - if k in req_iface and req_iface[k]: - # TODO: Should we allow static IP's somehow? - # Just validate the address, Docker will add it to the interface for us - #addr = ipaddress.ip_interface(req_iface[k]) - #for bridge_addr in bridge_addrs: - # if addr.ip == bridge_addr.ip: - # raise NetDhcpError(400, f'Address {addr} is already in use on bridge {bridge["ifname"]}') - raise NetDhcpError('Only the null IPAM driver is supported') - else: - dhcp = udhcpc.DHCPClient(if_container, v6=type_ == 'v6', once=True) - addr = dhcp.finish() - if not addr: - return - res_iface[k] = str(addr) - - if dhcp.gateway: - gateway_hints[endpoint_id] = dhcp.gateway - logger.info('Adding IP%s address %s to %s', type_, addr, if_container['ifname']) - - try_addr('v4') - if ipv6_enabled(network_id): - try_addr('v6') - - res = jsonify({ - 'Interface': res_iface - }) - except Exception as e: - logger.exception(e) - - if not isinstance(if_container, str): - (bridge - .del_port(if_host) - .commit()) - (if_host - .remove() - .commit()) - - if isinstance(e, NetDhcpError): - res = jsonify({'Err': str(e)}), e.status - else: - res = jsonify({'Err': str(e)}), 500 - finally: - return res - -@app.route('/NetworkDriver.EndpointOperInfo', methods=['POST']) -def endpoint_info(): - req = request.get_json(force=True) - - bridge = net_bridge(req['NetworkID']) - if_host, _if_container = veth_pair(req['EndpointID']) - if_host = ndb.interfaces[if_host] - - return jsonify({ - 'bridge': bridge['ifname'], - 'if_host': { - 'name': if_host['ifname'], - 'mac': if_host['address'] - } - }) - -@app.route('/NetworkDriver.DeleteEndpoint', methods=['POST']) -def delete_endpoint(): - req = request.get_json(force=True) - - bridge = net_bridge(req['NetworkID']) - if_host, _if_container = veth_pair(req['EndpointID']) - if_host = ndb.interfaces[if_host] - - bridge.del_port(if_host['ifname']) - (if_host - .remove() - .commit()) - - return jsonify({}) - -@app.route('/NetworkDriver.Join', methods=['POST']) -def join(): - req = request.get_json(force=True) - network = req['NetworkID'] - endpoint = req['EndpointID'] - - bridge = net_bridge(req['NetworkID']) - _if_host, if_container = veth_pair(req['EndpointID']) - - res = { - 'InterfaceName': { - 'SrcName': if_container, - 'DstPrefix': bridge['ifname'] - }, - 'StaticRoutes': [] - } - - if endpoint in gateway_hints: - gateway = gateway_hints[endpoint] - logger.info('Setting IPv4 gateway from DHCP (%s)', gateway) - res['Gateway'] = str(gateway) - del gateway_hints[endpoint] - - ipv6 = ipv6_enabled(network) - for route in bridge.routes: - if route['type'] != rtypes['RTN_UNICAST'] or \ - (route['family'] == socket.AF_INET6 and not ipv6): - continue - - if route['dst'] in ('', '/0'): - if route['family'] == socket.AF_INET and 'Gateway' not in res: - logger.info('Adding IPv4 gateway %s', route['gateway']) - res['Gateway'] = route['gateway'] - elif route['family'] == socket.AF_INET6 and 'GatewayIPv6' not in res: - logger.info('Adding IPv6 gateway %s', route['gateway']) - res['GatewayIPv6'] = route['gateway'] - elif route['gateway']: - dst = f'{route["dst"]}/{route["dst_len"]}' - logger.info('Adding route to %s via %s', dst, route['gateway']) - res['StaticRoutes'].append({ - 'Destination': dst, - 'RouteType': 0, - 'NextHop': route['gateway'] - }) - - container_dhcp_clients[endpoint] = ContainerDHCPManager(network, endpoint) - return jsonify(res) - -@app.route('/NetworkDriver.Leave', methods=['POST']) -def leave(): - req = request.get_json(force=True) - endpoint = req['EndpointID'] - - if endpoint in container_dhcp_clients: - container_dhcp_clients[endpoint].stop() - del container_dhcp_clients[endpoint] - - return jsonify({}) - -# Trying to grab the container's attributes (to get the network namespace) -# will deadlock (since Docker is waiting on us), so we must defer starting -# the DHCP client -class ContainerDHCPManager: - def __init__(self, network, endpoint): - self.network = network - self.endpoint = endpoint - self.ipv6 = ipv6_enabled(network) - - self.dhcp = None - self.dhcp6 = None - self._thread = threading.Thread(target=self.run) - self._thread.start() - - def _on_event(self, dhcp, event_type, _event): - if event_type != udhcpc.EventType.RENEW or not dhcp.gateway: - return - - logger.info('[dhcp container] Replacing gateway with %s', dhcp.gateway) - subprocess.check_call(['nsenter', f'-n{dhcp.netns}', '--', '/sbin/ip', 'route', 'replace', 'default', 'via', - str(dhcp.gateway)], timeout=1, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) - - # TODO: Adding default route with NDB seems to be broken (because of the dst syntax?) - #for route in ndb.routes: - # if route['type'] != rtypes['RTN_UNICAST'] or \ - # route['oif'] != dhcp.iface['index'] or \ - # (route['family'] == socket.AF_INET6 and not self.ipv6) or \ - # route['dst'] not in ('', '/0'): - # continue - - # # Needed because Route.remove() doesn't like a blank destination - # logger.info('Removing default route via %s', route['gateway']) - # route['dst'] = '::' if route['family'] == socket.AF_INET6 else '0.0.0.0' - # (route - # .remove() - # .commit()) - - #logger.info('Adding default route via %s', dhcp.gateway) - #(ndb.routes.add({'oif': dhcp.iface['index'], 'gateway': dhcp.gateway}) - # .commit()) - - def run(self): - try: - iface = await_endpoint_container_iface(self.network, self.endpoint) - hostname = endpoint_container_hostname(self.network, self.endpoint) - - self.dhcp = udhcpc.DHCPClient(iface, event_listener=self._on_event, hostname=hostname) - logger.info('Starting DHCPv4 client on %s in container namespace %s', iface['ifname'], \ - self.dhcp.netns) - - if self.ipv6: - self.dhcp6 = udhcpc.DHCPClient(iface, v6=True, event_listener=self._on_event, hostname=hostname) - logger.info('Starting DHCPv6 client on %s in container namespace %s', iface['ifname'], \ - self.dhcp6.netns) - except Exception as e: - logger.exception(e) - if self.dhcp: - self.dhcp.finish(timeout=1) - - def stop(self): - if not self.dhcp: - return - - try: - logger.info('Shutting down DHCPv4 client on %s in container namespace %s', \ - self.dhcp.iface['ifname'], self.dhcp.netns) - self.dhcp.finish(timeout=1) - finally: - try: - if self.ipv6: - logger.info('Shutting down DHCPv6 client on %s in container namespace %s', \ - self.dhcp6.iface['ifname'], self.dhcp.netns) - self.dhcp6.finish(timeout=1) - finally: - self._thread.join() - - # we have to do this since the docker client leaks sockets... - global client - client.close() - client = docker.from_env() diff --git a/net-dhcp/udhcpc.py b/net-dhcp/udhcpc.py deleted file mode 100644 index 1528b8b..0000000 --- a/net-dhcp/udhcpc.py +++ /dev/null @@ -1,148 +0,0 @@ -from enum import Enum -import ipaddress -import json -import struct -import binascii -import os -from os import path -from select import select -import threading -import subprocess -import logging - -from eventfd import EventFD -import posix_ipc - -HANDLER_SCRIPT = path.join(path.dirname(__file__), 'udhcpc_handler.py') -AWAIT_INTERVAL = 0.1 -VENDOR_ID = 'docker' - -class EventType(Enum): - BOUND = 'bound' - RENEW = 'renew' - DECONFIG = 'deconfig' - LEASEFAIL = 'leasefail' - -logger = logging.getLogger('net-dhcp') - -class DHCPClientError(Exception): - pass - -def _nspopen_wrapper(netns): - return lambda cmd, *args, **kwargs: subprocess.Popen(['nsenter', f'-n{netns}', '--'] + cmd, *args, **kwargs) -class DHCPClient: - def __init__(self, iface, v6=False, once=False, hostname=None, event_listener=None): - self.iface = iface - self.v6 = v6 - self.once = once - self.event_listeners = [DHCPClient._attr_listener] - if event_listener: - self.event_listeners.append(event_listener) - - self.netns = None - if 'netns' in iface: - self.netns = iface['netns'] - logger.debug('udhcpc using netns %s', self.netns) - - Popen = _nspopen_wrapper(self.netns) if self.netns else subprocess.Popen - bin_path = '/usr/bin/udhcpc6' if v6 else '/sbin/udhcpc' - cmdline = [bin_path, '-s', HANDLER_SCRIPT, '-i', iface['ifname'], '-f'] - cmdline.append('-q' if once else '-R') - if hostname: - cmdline.append('-x') - if v6: - # TODO: We encode the fqdn for DHCPv6 because udhcpc6 seems to be broken - # flags: S bit set (see RFC4704) - enc_hostname = hostname.encode('utf-8') - enc_hostname = struct.pack('BB', 0b0001, len(enc_hostname)) + enc_hostname - enc_hostname = binascii.hexlify(enc_hostname).decode('ascii') - hostname_opt = f'0x27:{enc_hostname}' - else: - hostname_opt = f'hostname:{hostname}' - cmdline.append(hostname_opt) - if not v6: - cmdline += ['-V', VENDOR_ID] - - self._suffix = '6' if v6 else '' - self._event_queue = posix_ipc.MessageQueue(f'/udhcpc{self._suffix}_{iface["address"].replace(":", "_")}', \ - flags=os.O_CREAT | os.O_EXCL, max_messages=2, max_message_size=1024) - self.proc = Popen(cmdline, env={'EVENT_QUEUE': self._event_queue.name}, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - if hostname: - logger.debug('[udhcpc%s#%d] using hostname "%s"', self._suffix, self.proc.pid, hostname) - - self._has_lease = threading.Event() - self.ip = None - self.gateway = None - self.domain = None - - self._shutdown_event = EventFD() - self.shutdown = False - self._event_thread = threading.Thread(target=self._read_events) - self._event_thread.start() - - def _attr_listener(self, event_type, event): - if event_type in (EventType.BOUND, EventType.RENEW): - self.ip = ipaddress.ip_interface(event['ip']) - if 'gateway' in event: - self.gateway = ipaddress.ip_address(event['gateway']) - else: - self.gateway = None - self.domain = event.get('domain') - self._has_lease.set() - elif event_type == EventType.DECONFIG: - self._has_lease.clear() - self.ip = None - self.gateway = None - self.domain = None - - def _read_events(self): - while True: - r, _w, _e = select([self._shutdown_event, self._event_queue.mqd], [], []) - if self._shutdown_event in r: - break - - msg, _priority = self._event_queue.receive() - event = json.loads(msg.decode('utf-8')) - try: - event['type'] = EventType(event['type']) - except ValueError: - logger.warning('udhcpc%s#%d unknown event "%s"', self._suffix, self.proc.pid, event) - continue - - logger.debug('[udhcp%s#%d event] %s', self._suffix, self.proc.pid, event) - for listener in self.event_listeners: - try: - listener(self, event['type'], event) - except Exception as ex: - logger.exception(ex) - self.shutdown = True - del self._shutdown_event - - def await_ip(self, timeout=10): - if not self._has_lease.wait(timeout=timeout): - raise DHCPClientError(f'Timed out waiting for lease from udhcpc{self._suffix}') - - return self.ip - - def finish(self, timeout=5): - if self.shutdown or self._shutdown_event.is_set(): - return - - try: - if self.proc.returncode is not None and (not self.once or self.proc.returncode != 0): - raise DHCPClientError(f'udhcpc{self._suffix} exited early with code {self.proc.returncode}') - if self.once: - self.await_ip() - else: - self.proc.terminate() - - if self.proc.wait(timeout=timeout) != 0: - raise DHCPClientError(f'udhcpc{self._suffix} exited with non-zero exit code {self.proc.returncode}') - - return self.ip - finally: - self._shutdown_event.set() - self._event_thread.join() - self._event_queue.close() - self._event_queue.unlink() diff --git a/net-dhcp/udhcpc_handler.py b/net-dhcp/udhcpc_handler.py deleted file mode 100755 index 65374aa..0000000 --- a/net-dhcp/udhcpc_handler.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python -import json -import sys -from os import environ as env - -import posix_ipc - -if __name__ != '__main__': - print('You shouldn\'t be importing this script!') - sys.exit(1) - -event = {'type': sys.argv[1]} -if event['type'] in ('bound', 'renew'): - if 'ipv6' in env: - event['ip'] = env['ipv6'] - else: - event['ip'] = f'{env["ip"]}/{env["mask"]}' - if 'router' in env: - event['gateway'] = env['router'] - if 'domain' in env: - event['domain'] = env['domain'] -elif event['type'] in ('deconfig', 'leasefail', 'nak'): - pass -else: - event['type'] = 'unknown' - -queue = posix_ipc.MessageQueue(env['EVENT_QUEUE']) -queue.send(json.dumps(event)) -queue.close() diff --git a/pkg/plugin/endpoints.go b/pkg/plugin/endpoints.go new file mode 100644 index 0000000..039c527 --- /dev/null +++ b/pkg/plugin/endpoints.go @@ -0,0 +1,61 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "net/http" + + log "github.com/sirupsen/logrus" +) + +// ParseJSONBody attempts to parse the request body as JSON +func ParseJSONBody(v interface{}, w http.ResponseWriter, r *http.Request) error { + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() + if err := d.Decode(v); err != nil { + JSONErrResponse(w, fmt.Errorf("failed to parse request body: %w", err), http.StatusBadRequest) + return err + } + + return nil +} + +// JSONResponse Sends a JSON payload in response to a HTTP request +func JSONResponse(w http.ResponseWriter, v interface{}, statusCode int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + enc := json.NewEncoder(w) + if err := enc.Encode(v); err != nil { + log.WithField("err", err).Error("Failed to serialize JSON payload") + + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "Failed to serialize JSON payload") + } +} + +type jsonError struct { + Message string `json:"message"` +} + +// JSONErrResponse Sends an `error` as a JSON object with a `message` property +func JSONErrResponse(w http.ResponseWriter, err error, statusCode int) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(statusCode) + + enc := json.NewEncoder(w) + enc.Encode(jsonError{err.Error()}) +} + +// CapabilitiesResponse returns whether or not this network is global or local +type CapabilitiesResponse struct { + Scope string + ConnectivityScope string +} + +func apiGetCapabilities(w http.ResponseWriter, r *http.Request) { + JSONResponse(w, CapabilitiesResponse{ + Scope: "local", + ConnectivityScope: "global", + }, http.StatusOK) +} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go new file mode 100644 index 0000000..3d743c4 --- /dev/null +++ b/pkg/plugin/plugin.go @@ -0,0 +1,40 @@ +package plugin + +import ( + "net" + "net/http" +) + +// Plugin is the DHCP network plugin +type Plugin struct { + server http.Server +} + +// NewPlugin creates a new Plugin +func NewPlugin() *Plugin { + p := Plugin{} + + mux := http.NewServeMux() + mux.HandleFunc("/NetworkDriver.GetCapabilities", apiGetCapabilities) + + p.server = http.Server{ + Handler: mux, + } + + return &p +} + +// Start starts the plugin server +func (p *Plugin) Start(bindSock string) error { + l, err := net.Listen("unix", bindSock) + if err != nil { + return err + } + + return p.server.Serve(l) +} + +// Stop stops the plugin server +func (p *Plugin) Stop() error { + return p.server.Close() +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 50bf149..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -flask==1.1.1 -pyroute2==0.5.6 -docker==4.0.2 -eventfd==0.2 -posix_ipc==1.0.4