Initial Go rewrite
parent
866bb1b5cd
commit
bd8e73f41a
@ -1,4 +1,5 @@
|
|||||||
|
/bin/*
|
||||||
|
!/bin/.gitkeep
|
||||||
/plugin/
|
/plugin/
|
||||||
__pycache__/
|
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
@ -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"]
|
|
@ -1,40 +1,39 @@
|
|||||||
PLUGIN_NAME = devplayer0/net-dhcp
|
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:
|
clean:
|
||||||
@echo "### rm ./plugin"
|
-rm -rf ./plugin
|
||||||
@rm -rf ./plugin
|
-rm - bin/*
|
||||||
|
|
||||||
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}
|
|
||||||
|
@ -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()
|
||||||
|
}
|
@ -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
|
||||||
|
)
|
@ -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=
|
@ -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
|
|
@ -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)
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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)
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
@ -1,5 +0,0 @@
|
|||||||
flask==1.1.1
|
|
||||||
pyroute2==0.5.6
|
|
||||||
docker==4.0.2
|
|
||||||
eventfd==0.2
|
|
||||||
posix_ipc==1.0.4
|
|
Loading…
Reference in New Issue