├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_connector.py │ └── test_kea.py └── fixtures │ ├── __init__.py │ └── pynetbox │ ├── __init__.py │ ├── ip_ranges.py │ ├── vminterfaces.py │ ├── virtual_machines.py │ ├── devices.py │ ├── prefixes.py │ ├── interfaces.py │ └── ip_addresses.py ├── src └── netboxkea │ ├── __init__.py │ ├── kea │ ├── __init__.py │ ├── exceptions.py │ ├── api.py │ └── app.py │ ├── __about__.py │ ├── logger.py │ ├── entry_point.py │ ├── netbox.py │ ├── listener.py │ ├── config.py │ └── connector.py ├── .dockerignore ├── Dockerfile ├── examples ├── systemd-netbox-kea-dhcp.service ├── nginx-reverse-proxy └── netbox-kea-dhcp.example.toml ├── LICENSE ├── pyproject.toml ├── .github └── workflows │ ├── python-app.yml │ └── publish-oci.yaml ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/netboxkea/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/netboxkea/kea/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/netboxkea/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1a10" 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | tests/ 2 | examples/ 3 | dist/ 4 | venv/ 5 | Dockerfile 6 | .gitignore -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine as base 2 | 3 | # Build 4 | FROM base as builder 5 | COPY . /source 6 | RUN python -m venv /venv 7 | RUN /venv/bin/pip install /source 8 | 9 | # Run 10 | FROM base as runner 11 | COPY --from=builder /venv /venv 12 | ENTRYPOINT ["/venv/bin/netbox-kea-dhcp"] -------------------------------------------------------------------------------- /examples/systemd-netbox-kea-dhcp.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Netbox to Kea DHCP connector 3 | After=isc-kea-dhcp4-server.service 4 | 5 | [Service] 6 | User=_kea 7 | Group=_kea 8 | ExecStart=/usr/local/bin/netbox-kea-dhcp -c /etc/netbox-kea-dhcp.toml 9 | Type=exec 10 | Restart=on-abnormal 11 | RestartSec=5 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /src/netboxkea/kea/exceptions.py: -------------------------------------------------------------------------------- 1 | class KeaError(Exception): 2 | pass 3 | 4 | 5 | class KeaServerError(KeaError): 6 | pass 7 | 8 | 9 | class KeaClientError(KeaError): 10 | pass 11 | 12 | 13 | class SubnetNotEqual(KeaError): 14 | pass 15 | 16 | 17 | class SubnetNotFound(KeaClientError): 18 | pass 19 | 20 | 21 | class DuplicateValue(KeaClientError): 22 | pass 23 | 24 | 25 | class KeaCmdError(KeaClientError): 26 | pass 27 | -------------------------------------------------------------------------------- /examples/nginx-reverse-proxy: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8443 ssl default_server; 3 | server_name _; 4 | 5 | ssl_certificate /etc/ssl/server.pem; 6 | ssl_certificate_key /etc/ssl/private/server.key; 7 | 8 | # Note: You should disable gzip for SSL traffic. 9 | # See: https://bugs.debian.org/773332 10 | gzip off; 11 | 12 | location / { 13 | proxy_pass http://127.0.0.1:8001; 14 | proxy_read_timeout 600s; 15 | proxy_send_timeout 600s; 16 | include proxy_params; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/ip_ranges.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pynetbox.core.response import Record 4 | from pynetbox.models.ipam import IpRanges 5 | 6 | api = Mock(base_url='http://netbox') 7 | 8 | _common = { 9 | 'comments': '', 10 | 'created': '2023-01-01T12:00:00.000000Z', 11 | 'custom_fields': {}, 12 | 'description': '', 13 | 'family': Record({'label': 'IPv4', 'value': 4}, api, None), 14 | 'has_details': False, 15 | 'last_updated': '2023-01-01T12:00:00.000000Z', 16 | 'role': None, 17 | 'size': 100, 18 | 'tags': [], 19 | 'tenant': None, 20 | 'vrf': None} 21 | 22 | _r_250 = _common.copy() 23 | _r_250.update({ 24 | 'display': '192.168.0.100-200/24', 25 | 'end_address': '192.168.0.199/24', 26 | 'id': 250, 27 | 'start_address': '192.168.0.100/24', 28 | 'status': Record({'label': 'DHCP', 'value': 'dhcp'}, api, None), 29 | 'url': 'http://netbox/api/ipam/ip-ranges/250/'}) 30 | 31 | ip_range_250 = IpRanges(_r_250, api, None) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 francoismdj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/vminterfaces.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pynetbox.core.response import Record 4 | 5 | from . import virtual_machines 6 | 7 | api = Mock(base_url='http://netbox') 8 | 9 | # Note: record method "full_detail()" was called 10 | _common = { 11 | 'bridge': None, 12 | 'count_fhrp_groups': 0, 13 | 'created': '2023-01-01T12:00:00.000000Z', 14 | 'custom_fields': {}, 15 | 'description': '', 16 | 'enabled': True, 17 | 'has_details': True, 18 | 'l2vpn_termination': None, 19 | 'last_updated': '2023-03-28T08:15:56.256950Z', 20 | 'mac_address': '55:55:55:55:55:55', 21 | 'mode': None, 22 | 'mtu': None, 23 | 'parent': None, 24 | 'tagged_vlans': [], 25 | 'tags': [], 26 | 'untagged_vlan': None, 27 | 'vrf': None} 28 | 29 | _vif_350 = _common.copy() 30 | _vif_350.update({ 31 | 'count_ipaddresses': 1, 32 | 'display': 'vm001-if0', 33 | 'id': 350, 34 | 'name': 'vm001-if0', 35 | 'virtual_machine': virtual_machines.virtual_machine_450, 36 | 'url': 'http://netbox:8000/api/virtualization/interfaces/350/'}) 37 | 38 | vminterface_350 = Record(_vif_350, api, None) 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "netbox-kea-dhcp" 7 | dynamic = ["version"] 8 | authors = [{ name="francoismdj", email="" },] 9 | description = "Use netbox as subnets source for ISC Kea DHCP server" 10 | readme = "README.md" 11 | requires-python = ">=3.8" 12 | license = "MIT" 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Topic :: System :: Systems Administration", 22 | "Intended Audience :: Information Technology" 23 | ] 24 | dependencies = [ 25 | "pynetbox~=7.0.1", 26 | "bottle~=0.12.25", 27 | "tomli >= 1.1.0 ; python_version < '3.11'" 28 | ] 29 | 30 | [project.urls] 31 | Homepage = "https://github.com/francoismdj/netbox-kea-dhcp" 32 | 33 | [project.scripts] 34 | netbox-kea-dhcp = "netboxkea.entry_point:run" 35 | 36 | [tool.hatch.version] 37 | path = "src/netboxkea/__about__.py" 38 | 39 | [tool.hatch.build] 40 | sources = ["src"] 41 | 42 | [tool.hatch.build.force-include] 43 | "examples/" = "examples" 44 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 30 | pip install -e ./ 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | - name: Test with unittest 38 | run: | 39 | python -m unittest discover 40 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/virtual_machines.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pynetbox.core.response import Record 4 | from pynetbox.models.virtualization import VirtualMachines 5 | 6 | api = Mock(base_url='http://netbox') 7 | 8 | _common = { 9 | 'cluster': Record({'display': 'My cluster'}, api, None), 10 | 'comments': '', 11 | 'config_context': {}, 12 | 'created': '2023-01-01T12:00:00.000000Z', 13 | 'custom_fields': {}, 14 | 'device': None, 15 | 'disk': None, 16 | 'has_details': False, 17 | 'last_updated': '2023-01-01T12:00:00.000000Z', 18 | 'local_context_data': None, 19 | 'memory': None, 20 | 'platform': None, 21 | 'primary_ip6': None, 22 | 'role': None, 23 | 'site': None, 24 | 'status': Record({'label': 'Active', 'value': 'active'}, api, None), 25 | 'tags': [], 26 | 'tenant': None, 27 | 'vcpus': None} 28 | 29 | _vm_450 = _common.copy() 30 | _vm_450.update({ 31 | 'display': 'vm', 32 | 'id': 450, 33 | 'name': 'vm', 34 | # Primary IP addresse associations are postponed into ip_addresses module 35 | # to avoid circular import failures. 36 | # 'primary_ip': ip_addresse.ip_address_300, 37 | # 'primary_ip4': ip_addresses.ip_address_300, 38 | 'url': 'http://netbox/api/virtualization/virtual-machines/450/'}) 39 | 40 | virtual_machine_450 = VirtualMachines(_vm_450, api, None) 41 | -------------------------------------------------------------------------------- /src/netboxkea/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import syslog 3 | 4 | _SD_DAEMON_MAP = { 5 | logging.DEBUG: syslog.LOG_DEBUG, 6 | logging.INFO: syslog.LOG_INFO, 7 | logging.WARNING: syslog.LOG_WARNING, 8 | logging.ERROR: syslog.LOG_ERR, 9 | logging.CRITICAL: syslog.LOG_CRIT 10 | } 11 | 12 | 13 | def init_logger(log_level_name, ext_log_level_name, syslog_level_prefix): 14 | """ Configure loggers """ 15 | 16 | log_level = _level_name_to_int(log_level_name) 17 | if syslog_level_prefix: 18 | logger = logging.getLogger() 19 | logger.setLevel(log_level) 20 | ch = logging.StreamHandler() 21 | ch.setFormatter(_SdDaemonFormatter()) 22 | logger.addHandler(ch) 23 | else: 24 | logging.basicConfig( 25 | level=log_level, format='%(asctime)s [%(levelname)s] %(message)s') 26 | logger = logging.getLogger() 27 | 28 | # Log level for external modules 29 | ext_log_level = _level_name_to_int(ext_log_level_name) 30 | logging.getLogger('urllib3.connectionpool').setLevel(ext_log_level) 31 | 32 | 33 | def _level_name_to_int(name): 34 | num = getattr(logging, name.upper(), None) 35 | if not isinstance(num, int): 36 | raise ValueError(f'Invalid log level name: {name}') 37 | return num 38 | 39 | 40 | class _SdDaemonFormatter(logging.Formatter): 41 | def format(self, record): 42 | sd_levelno = _SD_DAEMON_MAP.get(record.levelno, syslog.LOG_INFO) 43 | return f'<{sd_levelno}>' + super().format(record) 44 | -------------------------------------------------------------------------------- /src/netboxkea/entry_point.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .config import get_config 4 | from .connector import Connector 5 | from .kea.app import DHCP4App 6 | from .listener import WebhookListener 7 | from .logger import init_logger 8 | from .netbox import NetboxApp 9 | 10 | 11 | def run(): 12 | conf = get_config() 13 | init_logger(conf.log_level, conf.ext_log_level, conf.syslog_level_prefix) 14 | 15 | # Instanciate source, sink and connector 16 | logging.info(f'netbox: {conf.netbox_url}, kea: {conf.kea_url}') 17 | nb = NetboxApp( 18 | conf.netbox_url, conf.netbox_token, prefix_filter=conf.prefix_filter, 19 | iprange_filter=conf.iprange_filter, 20 | ipaddress_filter=conf.ipaddress_filter) 21 | kea = DHCP4App(conf.kea_url) 22 | conn = Connector( 23 | nb, kea, conf.subnet_prefix_map, conf.pool_iprange_map, 24 | conf.reservation_ipaddr_map, check=conf.check_only) 25 | 26 | if not conf.full_sync_at_startup and not conf.listen: 27 | logging.warning('Neither full sync nor listen mode has been asked') 28 | 29 | # Start a full synchronisation 30 | if conf.full_sync_at_startup: 31 | logging.info('Start full sync') 32 | conn.sync_all() 33 | 34 | # Start listening for events 35 | if conf.listen: 36 | logging.info(f'Listen for events on {conf.bind}:{conf.port}') 37 | server = WebhookListener( 38 | connector=conn, host=conf.bind, port=conf.port, secret=conf.secret, 39 | secret_header=conf.secret_header) 40 | server.run() 41 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/devices.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pynetbox.core.response import Record 4 | from pynetbox.models.dcim import Devices 5 | 6 | api = Mock(base_url='http://netbox') 7 | 8 | _common = { 9 | 'airflow': None, 10 | 'asset_tag': None, 11 | 'cluster': None, 12 | 'comments': '', 13 | 'config_context': {}, 14 | 'created': '2023-01-01T12:00:00.000000Z', 15 | 'custom_fields': {}, 16 | 'device_role': Record({'display': 'Unknown'}, api, None), 17 | 'device_type': Record({'display': 'Unknown'}, api, None), 18 | 'face': None, 19 | 'has_details': False, 20 | 'last_updated': '2023-01-01T12:00:00.000000Z', 21 | 'local_context_data': None, 22 | 'location': None, 23 | 'parent_device': None, 24 | 'platform': None, 25 | 'position': None, 26 | 'primary_ip6': None, 27 | 'rack': None, 28 | 'serial': '', 29 | 'site': None, 30 | 'status': Record({'label': 'Active', 'value': 'active'}, api, None), 31 | 'tags': [], 32 | 'tenant': None, 33 | 'vc_position': None, 34 | 'vc_priority': None, 35 | 'virtual_chassis': None} 36 | 37 | _dev_400 = _common.copy() 38 | _dev_400.update({ 39 | 'display': 'pc', 40 | 'id': 400, 41 | 'name': 'pc', 42 | # Primary IP addresse associations are postponed into ip_addresses module 43 | # to avoid circular import failures. 44 | # 'primary_ip': ip_addresses.ip_address_200, 45 | # 'primary_ip4': ip_addresses.ip_address_200, 46 | 'url': 'http://netbox/api/dcim/devices/400/'}) 47 | 48 | device_400 = Devices(_dev_400, api, None) 49 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/prefixes.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pynetbox.core.response import Record 4 | from pynetbox.models.ipam import Prefixes 5 | 6 | api = Mock(base_url='http://netbox') 7 | 8 | _common = { 9 | 'children': 0, 10 | 'comments': '', 11 | 'created': '2023-01-01T12:00:00.000000Z', 12 | 'custom_fields': {'dhcp_enable': True, 13 | 'dhcp_option_data_domain_search': 'local, lan', 14 | 'dhcp_option_data_routers': '192.168.0.254'}, 15 | 'description': '', 16 | 'family': Record({'label': 'IPv4', 'value': 4}, api, None), 17 | 'has_details': False, 18 | 'is_pool': False, 19 | 'last_updated': '2023-01-01T12:00:00.000000Z', 20 | 'mark_utilized': False, 21 | 'role': None, 22 | 'site': None, 23 | 'status': Record({'label': 'Active', 'value': 'active'}, api, None), 24 | 'tags': [], 25 | 'tenant': None, 26 | 'url': 'http://netbox/api/ipam/prefixes/100/', 27 | 'vlan': None, 28 | 'vrf': None} 29 | 30 | _pref_100 = _common.copy() 31 | _pref_100.update({ 32 | 'display': '192.168.0.0/24', 33 | 'id': 100, 34 | 'prefix': '192.168.0.0/24'}) 35 | 36 | prefix_100 = Prefixes(_pref_100, api, None) 37 | 38 | _pref_101 = _common.copy() 39 | _pref_101.update({ 40 | 'custom_fields': {'dhcp_enable': True, 41 | 'dhcp_option_data_domain_search': 'local, lan10', 42 | 'dhcp_option_data_routers': '10.254.254.254'}, 43 | 'display': '10.0.0.0/8', 44 | 'id': 101, 45 | 'prefix': '10.0.0.0/8'}) 46 | prefix_101 = Prefixes(_pref_101, api, None) 47 | -------------------------------------------------------------------------------- /.github/workflows/publish-oci.yaml: -------------------------------------------------------------------------------- 1 | name: Publish OCI Images 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | publish: 8 | name: "Build and publish OCI on GHCR" 9 | runs-on: ubuntu-latest 10 | permissions: 11 | packages: write 12 | steps: 13 | - name: Save the date 14 | id: date 15 | run: | 16 | echo date=$(date --rfc-3339=seconds) >> $GITHUB_OUTPUT 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | - name: Login to GHCR 20 | uses: docker/login-action@v2 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ secrets.GITHUB_TOKEN }} 25 | - name: Set up buildx 26 | uses: docker/setup-buildx-action@v2 27 | - name: Build and push containers 28 | uses: docker/build-push-action@v4 29 | with: 30 | push: true 31 | platforms: linux/amd64 32 | tags: | 33 | ghcr.io/${{ github.repository }}:${{ github.ref_name }} 34 | labels: | 35 | org.opencontainers.image.title=${{ github.event.repository.name }} 36 | org.opencontainers.image.description=${{ github.event.repository.description }} 37 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 38 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 39 | org.opencontainers.image.created=${{ steps.date.outputs.date }} 40 | org.opencontainers.image.version=${{ steps.date.outputs.tag }} 41 | org.opencontainers.image.revision=${{ github.sha }} 42 | org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} 43 | -------------------------------------------------------------------------------- /src/netboxkea/netbox.py: -------------------------------------------------------------------------------- 1 | import pynetbox 2 | from ipaddress import ip_interface, ip_network 3 | 4 | 5 | class NetboxApp: 6 | 7 | def __init__(self, url, token, prefix_filter={}, iprange_filter={}, 8 | ipaddress_filter={'status': 'dhcp'}): 9 | self.nb = pynetbox.api(url, token=token) 10 | self.prefix_filter = prefix_filter 11 | self.iprange_filter = iprange_filter 12 | self.ipaddress_filter = ipaddress_filter 13 | 14 | def prefix(self, id_): 15 | return self.nb.ipam.prefixes.get(id=id_, **self.prefix_filter) 16 | 17 | def prefixes(self, contains): 18 | return self.nb.ipam.prefixes.filter( 19 | **self.prefix_filter, contains=contains) 20 | 21 | def all_prefixes(self): 22 | return self.nb.ipam.prefixes.filter(**self.prefix_filter) 23 | 24 | def ip_range(self, id_): 25 | return self.nb.ipam.ip_ranges.get(id=id_, **self.iprange_filter) 26 | 27 | def ip_ranges(self, parent): 28 | # Emulate "parent" filter as NetBox API doesn’t support it on 29 | # ip-ranges objects (v3.4). 30 | parent_net = ip_network(parent) 31 | for r in self.nb.ipam.ip_ranges.filter( 32 | parent=parent, **self.iprange_filter): 33 | if (ip_interface(r.start_address) in parent_net 34 | and ip_interface(r.end_address) in parent_net): 35 | yield r 36 | 37 | def ip_address(self, id_): 38 | return self.nb.ipam.ip_addresses.get(id=id_, **self.ipaddress_filter) 39 | 40 | def ip_addresses(self, **filters): 41 | if not filters: 42 | raise ValueError( 43 | 'Netboxapp.ip_addresses() requires at least one keyword arg') 44 | for i in self.nb.ipam.ip_addresses.filter( 45 | **self.ipaddress_filter, **filters): 46 | yield i 47 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/interfaces.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pynetbox.core.response import Record 4 | from pynetbox.models.dcim import Interfaces 5 | 6 | from . import devices 7 | 8 | api = Mock(base_url='http://netbox') 9 | 10 | _common = { 11 | '_occupied': False, 12 | 'bridge': None, 13 | 'cable': None, 14 | 'cable_end': '', 15 | 'created': '2023-01-01T12:00:00.000000Z', 16 | 'connected_endpoints': None, 17 | 'connected_endpoints_reachable': None, 18 | 'connected_endpoints_type': None, 19 | 'count_fhrp_groups': 0, 20 | 'custom_fields': {}, 21 | 'description': '', 22 | 'duplex': None, 23 | 'enabled': True, 24 | 'has_details': False, 25 | 'l2vpn_termination': None, 26 | 'label': '', 27 | 'lag': None, 28 | 'last_updated': '2023-01-01T12:00:00.000000Z', 29 | 'link_peers': [], 30 | 'link_peers_type': None, 31 | 'mark_connected': False, 32 | 'mgmt_only': False, 33 | 'mode': None, 34 | 'module': None, 35 | 'mtu': None, 36 | 'parent': None, 37 | 'poe_mode': None, 38 | 'poe_type': None, 39 | 'rf_channel': None, 40 | 'rf_channel_frequency': None, 41 | 'rf_channel_width': None, 42 | 'rf_role': None, 43 | 'speed': None, 44 | 'tagged_vlans': [], 45 | 'tags': [], 46 | 'tx_power': None, 47 | 'type': Record( 48 | {'label': '1000BASE-T (1GE)', 'value': '1000base-t'}, api, None), 49 | 'untagged_vlan': None, 50 | 'vdcs': [], 51 | 'vrf': None, 52 | 'wireless_lans': [], 53 | 'wireless_link': None, 54 | 'wwn': None} 55 | 56 | # pynetbox record __dict__ attribute 57 | _if_300 = _common.copy() 58 | _if_300.update({ 59 | 'count_ipaddresses': 1, 60 | #'device': tests.fixtures.pynetbox.devices.device_400, 61 | 'device': devices.device_400, 62 | 'display': 'pc-if0', 63 | 'id': 300, 64 | 'mac_address': '11:11:11:11:11:11', 65 | 'name': 'pc-if0', 66 | 'url': 'http://netbox/api/dcim/interfaces/300/', 67 | }) 68 | 69 | interface_300 = Interfaces(_if_300, api, None) 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .*.swp 131 | .*.swo 132 | -------------------------------------------------------------------------------- /src/netboxkea/kea/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import requests 4 | 5 | from .exceptions import KeaServerError, KeaCmdError 6 | 7 | 8 | class FileAPI: 9 | """ Fake Kea DHCP4 API that keep configuration in memory and file """ 10 | 11 | def __init__(self, uri): 12 | self.config_file = uri 13 | if self.config_file: 14 | try: 15 | with open(self.config_file, 'rb') as f: 16 | self.conf = json.load(f) 17 | except FileNotFoundError: 18 | self.conf = {} 19 | else: 20 | self.conf = {} 21 | 22 | def get_conf(self): 23 | return self.conf.get('Dhcp4', {}) 24 | 25 | def raise_conf_error(self, config): 26 | json.dumps(config) 27 | 28 | def set_conf(self, config): 29 | self.raise_conf_error(config) 30 | self.conf['Dhcp4'] = config 31 | 32 | def write_conf(self): 33 | if self.config_file: 34 | with open(self.config_file, 'w') as f: 35 | json.dump(self.conf, f, indent=4) 36 | 37 | 38 | class DHCP4API: 39 | def __init__(self, url): 40 | self.url = url 41 | self.session = requests.Session() 42 | 43 | def _request_kea(self, command, arguments={}): 44 | """ Send command to Kea APP """ 45 | 46 | payload = {'command': command, 'service': ['dhcp4']} 47 | if arguments: 48 | payload['arguments'] = arguments 49 | try: 50 | r = self.session.post(self.url, json=payload) 51 | r.raise_for_status() 52 | rj = r.json() 53 | except requests.exceptions.RequestException as e: 54 | raise KeaServerError(f'API error: {e}') 55 | # One single command should return a list with one single item 56 | assert len(rj) == 1 57 | rj = rj.pop(0) 58 | result, text = rj['result'], rj.get('text') 59 | if result != 0: 60 | raise KeaCmdError(f'command "{command}" returns "{text}"') 61 | else: 62 | logging.debug(f'command "{command}" OK (text: {text})') 63 | return rj.get('arguments') 64 | 65 | def get_conf(self): 66 | """ Return configuration from Kea """ 67 | 68 | return self._request_kea('config-get')['Dhcp4'] 69 | 70 | def raise_conf_error(self, config): 71 | """ Test configuration and raise errors """ 72 | 73 | self._request_kea('config-test', {'Dhcp4': config}) 74 | 75 | def set_conf(self, config): 76 | """ Set configuration on DHCP server """ 77 | 78 | self._request_kea('config-set', {'Dhcp4': config}) 79 | 80 | def write_conf(self): 81 | """ On DHCP server write configuration to persitent storage """ 82 | 83 | self._request_kea('config-write') 84 | -------------------------------------------------------------------------------- /src/netboxkea/listener.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from json.decoder import JSONDecodeError 3 | 4 | import bottle 5 | 6 | 7 | class WebhookListener: 8 | """ Listen for netbox webhook requests and change DHCP configuration """ 9 | 10 | def __init__(self, connector, host='127.0.0.1', port=8001, secret=None, 11 | secret_header=None): 12 | self.conn = connector 13 | self.host = host 14 | self.port = port 15 | self.secret = secret 16 | self.secret_header = secret_header 17 | 18 | def run(self): 19 | """ Start web server """ 20 | 21 | @bottle.route('/event//', 'POST') 22 | def new_event(name): 23 | """ Define an all-in-one route for our web server """ 24 | 25 | logging.debug(f'Receive data on /event/{name}/') 26 | 27 | # import json 28 | # body = bottle.request.body.getvalue() 29 | # try: 30 | # print(json.dumps(json.loads(body), indent=4)) 31 | # except Exception: 32 | # print(body.decode()) 33 | 34 | if (self.secret_header and bottle.request.get_header( 35 | self.secret_header) != self.secret): 36 | self._abort(403, 'wrong secret or secret header') 37 | 38 | # Parse JSON body from request 39 | try: 40 | body = bottle.request.json 41 | except JSONDecodeError: 42 | body = bottle.request.body.getvalue().decode() 43 | self._abort(400, f'malformed body (not JSON): {body}') 44 | 45 | logging.debug(f'Parsed JSON request: {body}') 46 | try: 47 | model, id_, event = ( 48 | body['model'], body['data']['id'], body['event']) 49 | except KeyError as e: 50 | self._abort(400, f'request missing key: {e}') 51 | 52 | try: 53 | sync_func = getattr(self.conn, f'sync_{model}') 54 | except AttributeError: 55 | self._abort(400, f'unsupported target "{model}"') 56 | else: 57 | logging.info(f'process event: {model} id={id_} {event}') 58 | # Reload DHCP config before applying any changes 59 | self.conn.reload_dhcp_config() 60 | sync_func(id_) 61 | 62 | bottle.response.status = 201 63 | 64 | # Push change to DHCP server 65 | self.conn.push_to_dhcp() 66 | 67 | # very basic health check, basically proves bottle is already/still running 68 | # enough for Kubernetes probes 69 | @bottle.route('/health/') 70 | def health(): 71 | return 'ok' 72 | 73 | # start server 74 | bottle.run(host=self.host, port=self.port) 75 | 76 | def _abort(self, code, msg): 77 | logging.error(msg) 78 | bottle.abort(code, msg) 79 | -------------------------------------------------------------------------------- /examples/netbox-kea-dhcp.example.toml: -------------------------------------------------------------------------------- 1 | # netbox-kea-dhcp configuration file (TOML format, see https://toml.io) 2 | 3 | # Check generated configuration but don’t push it to DHCP server 4 | #check_only = true 5 | 6 | # Full sync at application startup (overide current DHCP config) 7 | #full_sync_at_startup = true 8 | 9 | # Listen for NetBox events 10 | #listen = true 11 | #bind = "0.0.0.0" 12 | #port = 8001 13 | # Require a secret to be sent in Netbox events in HTTP header 14 | #secret = "CHANGE-ME-CHANGE-ME-CHANGE-ME-CHANGE-ME" 15 | #secret_header = "X-netbox2kea-secret" 16 | 17 | # Netbox URL where API is listening 18 | netbox_url = "http://10.94.135.32:8000/" 19 | netbox_token = "9123456789abcdef0123456789abcdef01234568" 20 | 21 | # Kea control agent URI 22 | kea_url = "http://10.94.135.209:8000/" 23 | 24 | #log_level = "debug" # or "info", "warning" (default), "error" 25 | #ext_log_level = "warning" # Log level for external modules 26 | 27 | # Prefix log messages with syslog level. Intended for systemd unit parameter 28 | # "SyslogLevelPrefix", as described on 29 | # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#SyslogLevelPrefix=. 30 | # Messages are always sent to standard or error outputs. 31 | # When set to false (default), a datetime and level name prefixes is used 32 | #syslog_level_prefix = true 33 | 34 | 35 | # 36 | # Netbox-DHCP maps: mapping between Kea DHCP settings and netbox fields 37 | # 38 | # If the DHCP attribute have a dot and the part before the dot is "option-data" 39 | # the part after the dot will be added to option-data list as a dictionary 40 | # with two keys: "name"={part after the dot} and "data"={value from netbox}. 41 | # If the part before the dot is not a known list as option-data, the part after 42 | # will be a nested dictionary. 43 | # Each netbox fields may contains several dots which are seperators between 44 | # nested objects or dictionary keys. 45 | # A Netbox map may be a list of netbox fields. In this case, the first 46 | # non-empty field will be used as the DHCP value. 47 | # In respect to TOML syntax, attribute with dots in name need to be 48 | # double-quoted. 49 | 50 | # Default subnet<->prefix map: 51 | #[subnet_prefix_map] 52 | #"option-data.routers" = "custom_fields.dhcp_option_data_routers" 53 | #"option-data.domain-search" = "custom_fields.dhcp_option_data_domain_search" 54 | #"option-data.domain-name-servers" = "custom_fields.dhcp_option_data_domain_name_servers" 55 | #next-server = "custom_fields.dhcp_next_server" 56 | #boot-file-name = "custom_fields.dhcp_boot_file_name" 57 | #valid-lifetime = "custom_fields.dhcp_valid_lifetime" 58 | 59 | # Example of a nested DHCP dictionary 60 | #"user-context.tenant" = "tenant.name" 61 | 62 | # Default pool<->IP range map: no map 63 | #[pool_iprange_map] 64 | 65 | # Default IP reservation<->IP address map 66 | #[reservation_ipaddr_map] 67 | # "hw-address" and "hostname" DHCP settings are required 68 | ## Get MAC address from custom field, fallback to assigned interface MAC address 69 | #hw-address = [ "custom_fields.dhcp_reservation_hw_address", 70 | # "assigned_object.mac_address" ] 71 | # Get hostname from DNS name, fallback to device/vm name 72 | #hostname = [ "dns_name", "assigned_object.device.name", 73 | # "assigned_object.virtual_machine.name"] 74 | 75 | 76 | # 77 | # Filters: params injected into netbox API queries to restrict object selection 78 | # 79 | # See API documentation on http://netbox-host/api/docs/. 80 | # IMPORTANT: non existent params are silently ignored!! 81 | 82 | # Default prefix filter: 83 | #[prefix_filter] 84 | #cf_dhcp_enabled = true 85 | 86 | # Example of a filte that includes only active prefixes 87 | [prefix_filter] 88 | status = "active" 89 | cf_dhcp_enabled = true 90 | 91 | # Default IP range filter: 92 | #[iprange_filter] 93 | ## ATTENTION: "dhcp" is a custom status value, it needs to be created (in v3.4) 94 | #status = "dhcp" 95 | 96 | # Default IP address filter: 97 | #[ipaddress_filter] 98 | #status = "dhcp" 99 | -------------------------------------------------------------------------------- /tests/fixtures/pynetbox/ip_addresses.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pynetbox.core.response import Record 4 | from pynetbox.models.ipam import IpAddresses 5 | 6 | from . import devices, interfaces, virtual_machines, vminterfaces 7 | 8 | ALL_IP = [] 9 | 10 | 11 | def get(id_): 12 | """ Emulate pynetbox.[…].Record.get()""" 13 | for ip in ALL_IP: 14 | if ip.id == id_: 15 | return ip 16 | 17 | 18 | def filter_(interface_id=None, device_id=None, vminterface_id=None, 19 | virtual_machine_id=None, **kwargs): 20 | """ Emulate pynetbox.[…].Record.filter()""" 21 | 22 | intf_id = vminterface_id if vminterface_id else interface_id 23 | if intf_id: 24 | return iter([ip for ip in ALL_IP if ip.assigned_object and 25 | ip.assigned_object.id == intf_id]) 26 | elif device_id: 27 | return iter([ip for ip in ALL_IP if 28 | ip.assigned_object_type == 'dcim.interface' 29 | and ip.assigned_object.device.id == device_id]) 30 | elif virtual_machine_id: 31 | return iter([ip for ip in ALL_IP if 32 | ip.assigned_object_type == 'virtualization.vminterface' 33 | and ip.assigned_object.virtual_machine.id == 34 | virtual_machine_id]) 35 | else: 36 | return iter(ALL_IP) 37 | 38 | 39 | api = Mock(base_url='http://netbox') 40 | 41 | _common = { 42 | 'assigned_object': None, 43 | 'assigned_object_id': None, 44 | 'assigned_object_type': None, 45 | 'comments': '', 46 | 'created': '2023-01-01T12:00:00.000000Z', 47 | 'custom_fields': {}, 48 | 'description': '', 49 | 'family': Record({'label': 'IPv4', 'value': 4}, api, None), 50 | 'has_details': False, 51 | 'last_updated': '2023-01-01T12:00:00.000000Z', 52 | 'nat_inside': None, 53 | 'nat_outside': [], 54 | 'role': None, 55 | 'tags': [], 56 | 'tenant': None, 57 | 'vrf': None} 58 | 59 | _ip_200 = _common.copy() 60 | _ip_200.update({ 61 | 'address': '192.168.0.1/24', 62 | 'assigned_object': interfaces.interface_300, 63 | 'assigned_object_id': 300, 64 | 'assigned_object_type': 'dcim.interface', 65 | 'display': '192.168.0.1/24', 66 | 'dns_name': 'pc.lan', 67 | 'id': 200, 68 | 'status': Record({'label': 'DHCP', 'value': 'dhcp'}, api, None), 69 | 'url': 'https://netbox/api/ipam/ip-addresses/200/'}) 70 | 71 | ip_address_200 = IpAddresses(_ip_200, api, None) 72 | ALL_IP.append(ip_address_200) 73 | # Associate here IP address with device to avoid circular import failure 74 | devices.device_400.primary_ip = ip_address_200 75 | devices.device_400.primary_ip4 = ip_address_200 76 | 77 | _ip_201 = _common.copy() 78 | _ip_201.update({ 79 | 'address': '192.168.0.2/24', 80 | 'custom_fields': {'dhcp_resa_hw_address': '22:22:22:22:22:22'}, 81 | 'display': '192.168.0.2/24', 82 | 'dns_name': 'pc2.lan', 83 | 'id': 201, 84 | 'status': Record({'label': 'DHCP', 'value': 'dhcp'}, api, None), 85 | 'url': 'https://netbox/api/ipam/ip-addresses/201/'}) 86 | ip_address_201 = IpAddresses(_ip_201, api, None) 87 | ALL_IP.append(ip_address_201) 88 | 89 | _ip_202 = _common.copy() 90 | _ip_202.update({ 91 | 'address': '192.168.0.3/24', 92 | 'assigned_object': interfaces.interface_300, 93 | 'assigned_object_id': 300, 94 | 'assigned_object_type': 'dcim.interface', 95 | 'custom_fields': {'dhcp_resa_hw_address': '33:33:33:33:33:33'}, 96 | 'display': '192.168.0.3/24', 97 | 'dns_name': 'pc3.lan', 98 | 'id': 202, 99 | 'status': Record({'label': 'DHCP', 'value': 'dhcp'}, api, None), 100 | 'url': 'https://netbox/api/ipam/ip-addresses/202/'}) 101 | ip_address_202 = IpAddresses(_ip_202, api, None) 102 | ALL_IP.append(ip_address_202) 103 | 104 | _ip_250 = _common.copy() 105 | _ip_250.update({ 106 | 'address': '10.0.0.50/8', 107 | 'assigned_object': vminterfaces.vminterface_350, 108 | 'assigned_object_id': 350, 109 | 'assigned_object_type': 'virtualization.vminterface', 110 | 'display': '10.0.0.50/8', 111 | 'dns_name': 'vm.lan10', 112 | 'id': 250, 113 | 'status': Record({'label': 'DHCP', 'value': 'dhcp'}, api, None), 114 | 'url': 'https://netbox/api/ipam/ip-addresses/250/'}) 115 | 116 | ip_address_250 = IpAddresses(_ip_250, api, None) 117 | ALL_IP.append(ip_address_250) 118 | # Associate here IP address with device to avoid circular import failure 119 | virtual_machines.virtual_machine_450.primary_ip = ip_address_250 120 | virtual_machines.virtual_machine_450.primary_ip4 = ip_address_250 121 | -------------------------------------------------------------------------------- /src/netboxkea/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from argparse import ArgumentParser 4 | from dataclasses import dataclass, field 5 | try: 6 | import tomllib 7 | except ModuleNotFoundError: 8 | import tomli as tomllib 9 | 10 | from .__about__ import __version__ 11 | 12 | 13 | @dataclass(frozen=True) 14 | class Config: 15 | config_file: str = None 16 | check_only: bool = False 17 | full_sync_at_startup: bool = False 18 | listen: bool = False 19 | bind: str = '127.0.0.1' 20 | port: int = 8001 21 | secret: str = None 22 | secret_header: str = 'X-netbox2kea-secret' 23 | log_level: str = 'warning' 24 | ext_log_level: str = 'warning' 25 | syslog_level_prefix: bool = False 26 | kea_url: str = None 27 | netbox_url: str = None 28 | netbox_token: str = None 29 | prefix_filter: dict = field(default_factory=lambda: { 30 | 'cf_dhcp_enabled': True}) 31 | ipaddress_filter: dict = field(default_factory=lambda: {'status': 'dhcp'}) 32 | iprange_filter: dict = field(default_factory=lambda: {'status': 'dhcp'}) 33 | subnet_prefix_map: dict = field(default_factory=lambda: { 34 | 'option-data.routers': 'custom_fields.dhcp_option_data_routers', 35 | 'option-data.domain-search': 36 | 'custom_fields.dhcp_option_data_domain_search', 37 | 'option-data.domain-name-servers': 38 | 'custom_fields.dhcp_option_data_domain_name_servers', 39 | 'next-server': 'custom_fields.dhcp_next_server', 40 | 'boot-file-name': 'custom_fields.dhcp_boot_file_name', 41 | 'valid-lifetime': 'custom_fields.dhcp_valid_lifetime'}) 42 | pool_iprange_map: dict = field(default_factory=lambda: {}) 43 | reservation_ipaddr_map: dict = field(default_factory=lambda: { 44 | # Get MAC address from custom field, fallback to assigned interface 45 | 'hw-address': ['custom_fields.dhcp_reservation_hw_address', 46 | 'assigned_object.mac_address'], 47 | # Get hostname from DNS name, fallback to device/vm name 48 | 'hostname': ['dns_name', 'assigned_object.device.name', 49 | 'assigned_object.virtual_machine.name'] 50 | }) 51 | 52 | 53 | def get_config(): 54 | settings = {} 55 | 56 | parser = ArgumentParser() 57 | parser.add_argument( 58 | '--version', action='version', version=f'Version {__version__}') 59 | parser.add_argument('-c', '--config-file', help='configuration file') 60 | parser.add_argument('-n', '--netbox-url', help='') 61 | parser.add_argument('-t', '--netbox-token', help='') 62 | parser.add_argument('-k', '--kea-url', help='') 63 | parser.add_argument( 64 | '-l', '--listen', action='store_true', default=None, help='') 65 | parser.add_argument('-b', '--bind', help='') 66 | parser.add_argument('-p', '--port', type=int, help='') 67 | parser.add_argument( 68 | '--secret', help=f'Default header: {Config.secret_header}') 69 | parser.add_argument( 70 | '-s', '--sync-now', action='store_true', dest='full_sync_at_startup', 71 | default=None, help='') 72 | parser.add_argument( 73 | '--check', action='store_true', dest='check_only', default=None, 74 | help='') 75 | parser.add_argument( 76 | '-v', '--verbose', action='count', default=0, 77 | help='Increase verbosity. May be specified up to 3 times') 78 | # TODO: parser.add_argument('-f', '--foreground', help='') 79 | args = parser.parse_args() 80 | 81 | # Load TOML config file 82 | if args.config_file is not None: 83 | with open(args.config_file, 'rb') as f: 84 | tomlconf = tomllib.load(f) 85 | settings.update(tomlconf) 86 | 87 | # Load non-None command line arguments 88 | if args.verbose == 1: 89 | args.log_level = 'info' 90 | elif args.verbose == 2: 91 | args.log_level = 'debug' 92 | settings['ext_log_level'] = 'info' 93 | elif args.verbose >= 3: 94 | args.log_level = 'debug' 95 | settings['ext_log_level'] = 'debug' 96 | del args.verbose 97 | settings.update({k: v for k, v in args.__dict__.items() if v is not None}) 98 | 99 | # Check existence of required settings 100 | for attr in ('kea_url', 'netbox_url'): 101 | if attr not in settings: 102 | logging.fatal( 103 | f'Setting "{attr}" not found, neither on command line ' 104 | 'arguments nor in configuration file (if any)') 105 | sys.exit(1) 106 | 107 | conf = Config(**settings) 108 | 109 | if not set(['hw-address', 'hostname']).issubset( 110 | conf.reservation_ipaddr_map): 111 | logging.fatal( 112 | 'Setting "reservation_ipaddr_map" must have a mapping for ' 113 | '"hw-address" and "hostname" DHCP parameters') 114 | sys.exit(1) 115 | 116 | return conf 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | netbox-kea-dhcp 2 | =============== 3 | 4 | [![PyPI](https://img.shields.io/pypi/v/netbox-kea-dhcp)](https://pypi.org/project/netbox-kea-dhcp/) 5 | 6 | Enable use of [NetBox](https://github.com/netbox-community/netbox) as a subnet 7 | configuration source for [ISC Kea DHCP server](https://www.isc.org/kea/). 8 | 9 | `netbox-kea-dhcp` is a one-way sync daemon that exports NetBox prefixes, IP 10 | ranges and IP addresses to respectively DHCP subnets, pools 11 | and host reservations. It listens for NetBox webhook events, and each time a 12 | change occured, it queries NetBox for the full changed data and update Kea 13 | throught its API. 14 | 15 | The program has two modes of operation: 16 | 17 | - Full sync at program startup: overwrite current DHCP subnets with new ones 18 | exported from NetBox. 19 | - Continuous event-driven sync: listen for NetBox webhook events and update 20 | DHCP configuration accordingly. 21 | 22 | Key features 23 | ------------ 24 | 25 | - Automatic sync from Netbox to Kea DHCP with virtualy no delay. 26 | - Update Kea configuration throught its control agent API: no direct 27 | configuration file overwrites, let’s the control agent manage the runtime 28 | and permanent configuration. 29 | - Only use open source Kea API commands (no ISC paid subscription required). 30 | - Submit new exported configuration to Kea check before applying it to runtime 31 | configuration. 32 | - Query NetBox only for the objects concerned by the event (incremental 33 | sync). 34 | - Get all NetBox data throught the well maintained 35 | [`pynetbox`](https://github.com/netbox-community/pynetbox) library: unique 36 | interface, loose dependency with NetBox internals (only with its API), 37 | reduced code to maintain. 38 | - Customizable NetBox query filters. 39 | - Customizable mapping between Netbox object attributes and DHCP settings. 40 | 41 | Requirements 42 | ------------ 43 | 44 | Python: >= 3.8 (tested on 3.10 but may works down to 3.8). 45 | 46 | Netbox: validated on API version 3.4. 47 | 48 | ISC Kea DHCP: validated on version 2.2.0. 49 | 50 | Install 51 | ------- 52 | 53 | ### With pip 54 | 55 | `netbox-kea-dhcp` is available on 56 | [PyPi](https://pypi.org/project/netbox-kea-dhcp/) and can be installed 57 | with `pip install netbox-kea-dhcp`. 58 | 59 | ### With pipx 60 | 61 | A convenient way is to use [pipx](https://pypa.github.io/pipx/) to install the 62 | application in an isolated environnement. 63 | 64 | Install `pipx` (below is for Linux, see 65 | [pipx homepage](https://pypa.github.io/pipx/) for other systems): 66 | 67 | ```sh 68 | python3 -m pip install --user pipx 69 | python3 -m pipx ensurepath 70 | ``` 71 | 72 | Install `netbox-kea-dhcp` from PyPi in a isolated environnement: 73 | 74 | ```sh 75 | pipx install netbox-kea-dhcp 76 | ``` 77 | 78 | Run: 79 | 80 | ``` 81 | netbox-kea-dhcp --help 82 | ``` 83 | 84 | Quick start 85 | ----------- 86 | 87 | Sync at startup then listen for netbox events: 88 | ```sh 89 | netbox-kea-dhcp --netbox-url http://netbox-host \ 90 | --netbox-token 0123456789ABCDEF \ 91 | --kea-url http://kea-api-host --sync-now --listen -v 92 | ``` 93 | 94 | The default mapping between netbox and Kea is: 95 | 96 | - prefixes are exported to subnets. 97 | - IP ranges are exported to pools. Custom status value `dhcp` may have to be 98 | created in netbox configuration file ([netbox-community/netbox#8054](https://github.com/netbox-community/netbox/issues/8054)): 99 | ```python 100 | FIELD_CHOICES = { 101 | 'ipam.IPRange.status+': ( 102 | ('dhcp', 'DHCP', 'red'), 103 | ) 104 | } 105 | ``` 106 | 107 | - IP Addresses are exported to reservations. Hardware address is mapped with IP 108 | address custom field `dhcp_reservation_hw_address` if it exists and is non 109 | null, otherwise it is mapped with the MAC address of the assigned object. 110 | 111 | At least one Netbox webhook needs to be configured for event listening. It has 112 | to notify all actions on DHCP-relevant objects: 113 | 114 | - Content types: 115 | * `IPAM`: `Prefix`, `IP Range`, `IP addresse`. 116 | * `DCIM`: `Interface`, `Device`. 117 | * `Virtualization`: `Interface`, `Virtual Machine`. 118 | - Events: `Creations`, `Updates`, `Deletions`. 119 | - HTTP Request: 120 | * URL: `http://{netbox-connector-host}:{port}/event/{free-text}/` 121 | * HTTP Method: `POST`. 122 | 123 | The field `free-text` is necessary to define several webhooks with same events. 124 | The connector only uses it in logs. 125 | 126 | More help with `netbox-kea-dhcp --help` and in the configuration file example 127 | under `examples/` (or under 128 | `~/.local/pipx/venvs/netbox-kea-dhcp/lib/python3.10/site-packages/examples/` if 129 | app was installed with pipx). 130 | 131 | Recommended Netbox webhooks 132 | --------------------------- 133 | 134 | Sysadmins should set several webhooks with conditions and restricted body 135 | template, in order to filter events and avoid unecessary network and CPU load. 136 | 137 | Below is a recommended webhook set-up. It assumes that DHCP hardware addresses 138 | are apped with netbox interface MAC addresses. If interfaces are not used 139 | (i.e. hardware addresses are only mapped with a custom field defined in netbox 140 | IP addresses), webhooks on (vm) interfaces, (virtual) devices are not needed. 141 | 142 | Common to all webhooks: 143 | 144 | - HTTP Request: 145 | * URL: `http://{netbox-connector-host}:{port}/event/{optional-free-text}/` 146 | * HTTP Method: `POST`. 147 | - Body template: 148 | 149 | ```json 150 | { "event": "{{ event }}", 151 | "model": "{{ model }}", 152 | "data": { "id": {{ data["id"] }} } 153 | } 154 | ``` 155 | 156 | Webhook 1: 157 | 158 | - Content types: `IPAM > Prefix`, `IPAM > IP Range`, `IPAM > IP Address`, 159 | `DCIM > Device`, `DCIM > Interface`, `Virtualization > Virtual Machine`, 160 | `Virtualization > Interface`. 161 | - Events: `Updates` 162 | - Conditions: none 163 | 164 | Webhook 2: 165 | 166 | - Content types: `IPAM > IP Address` 167 | - Events: `Creations`, `Deletions` 168 | - Conditions: 169 | 170 | ```json 171 | { "and": [ { "attr": "status.value", "value": "dhcp" } ] } 172 | ``` 173 | 174 | Webhook 3: 175 | 176 | - Content types: `IPAM > IP Range` 177 | - Events: `Creations`, `Deletions` 178 | - Conditions (note: you may have to customize status values to add `dhcp`): 179 | 180 | ```json 181 | { "and": [ { "attr": "status.value", "value": "dhcp"} ] } 182 | ``` 183 | 184 | Webhook 4: 185 | 186 | - Content types: `IPAM > Prefix` 187 | - Events: `Creations`, `Deletions` 188 | - Conditions: none, or a custom field 189 | 190 | ```json 191 | { "and": [ { "attr": "custom_fields.dhcp_enabled", "value": true } ] } 192 | ``` 193 | 194 | It’s also recommended to set a TLS-enabled reverse proxy in front of 195 | `netbox-kea-dhcp`. 196 | 197 | Limitations 198 | ----------- 199 | 200 | - When a change occured, the whole DHCP configuration is gotten from Kea, 201 | modified, and sent back. It may put some stress on the DHCP server in case of 202 | frequent changes. This is a limitation of Kea open source commands. A better 203 | update granularity would require an ISC paid subscription. 204 | - When Kea URI is of the form `file:///path/to/kea-config`, config is written 205 | to the file in an unsafe manner: if the write fails, the file will be 206 | inconsistent. This is because the file feature is intended for tests. 207 | -------------------------------------------------------------------------------- /tests/unit/test_connector.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, call 3 | 4 | from netboxkea.connector import _get_nested, _set_dhcp_attr, Connector 5 | from netboxkea.kea.exceptions import SubnetNotFound 6 | from ..fixtures.pynetbox import ip_addresses as fixtip 7 | from ..fixtures.pynetbox import ip_ranges as fixtr 8 | from ..fixtures.pynetbox import prefixes as fixtp 9 | 10 | 11 | class TestConnectorFunctions(unittest.TestCase): 12 | 13 | def test_01_get_nested_attr(self): 14 | obj = {'assigned': {'device': {'name': 'pc.lan'}}} 15 | hostname = _get_nested(obj, 'assigned.device.name') 16 | self.assertEqual(hostname, 'pc.lan') 17 | 18 | def test_02_set_dhcp_attr(self): 19 | dhcp_item = {} 20 | _set_dhcp_attr(dhcp_item, 'next-server', '10.0.0.1') 21 | _set_dhcp_attr(dhcp_item, 'option-data.routers', '192.168.0.254') 22 | _set_dhcp_attr(dhcp_item, 'option-data.domain-search', 'lan') 23 | _set_dhcp_attr(dhcp_item, 'user-context.desc', 'Test') 24 | _set_dhcp_attr(dhcp_item, 'user-context.note', 'Hello') 25 | self.assertEqual(dhcp_item, { 26 | 'next-server': '10.0.0.1', 27 | 'option-data': [ 28 | {'name': 'routers', 'data': '192.168.0.254'}, 29 | {'name': 'domain-search', 'data': 'lan'}], 30 | 'user-context': {'desc': 'Test', 'note': 'Hello'}}) 31 | 32 | 33 | class TestConnector(unittest.TestCase): 34 | 35 | def setUp(self): 36 | self.nb = Mock() 37 | self.kea = Mock() 38 | 39 | # Set up connector 40 | resa_ip_map = { 41 | 'hw-address': ['custom_fields.dhcp_resa_hw_address', 42 | 'assigned_object.mac_address'], 43 | 'hostname': ['dns_name', 'assigned_object.device.name', 44 | 'assigned_object.virtual_machine.name']} 45 | self.conn = Connector(self.nb, self.kea, {}, {}, resa_ip_map) 46 | 47 | # Set up netbox mock 48 | self.nb.prefix.return_value = fixtp.prefix_100 49 | self.nb.prefixes.return_value = iter([fixtp.prefix_100]) 50 | self.nb.all_prefixes.return_value = iter([fixtp.prefix_100]) 51 | self.nb.ip_range.return_value = fixtr.ip_range_250 52 | self.nb.ip_ranges.return_value = iter([fixtr.ip_range_250]) 53 | self.nb.ip_address.side_effect = fixtip.get 54 | self.nb.ip_addresses.side_effect = fixtip.filter_ 55 | 56 | # Define kea calls 57 | self.call_subnet100 = call(100, {'subnet': '192.168.0.0/24'}) 58 | self.call_subnet101 = call(101, {'subnet': '10.0.0.0/8'}) 59 | self.call_resa200 = call(100, 200, { 60 | 'ip-address': '192.168.0.1', 'hw-address': '11:11:11:11:11:11', 61 | 'hostname': 'pc.lan'}) 62 | self.call_resa201 = call(100, 201, { 63 | 'ip-address': '192.168.0.2', 'hw-address': '22:22:22:22:22:22', 64 | 'hostname': 'pc2.lan'}) 65 | self.call_resa202 = call(100, 202, { 66 | 'ip-address': '192.168.0.3', 'hw-address': '33:33:33:33:33:33', 67 | 'hostname': 'pc3.lan'}) 68 | self.call_resa250 = call(100, 250, { 69 | 'ip-address': '10.0.0.50', 'hw-address': '55:55:55:55:55:55', 70 | 'hostname': 'vm.lan10'}) 71 | self.call_pool250 = call(100, 250, { 72 | 'pool': '192.168.0.100-192.168.0.199'}) 73 | 74 | def test_01_sync_ip_address_with_assigned_interface(self): 75 | self.conn.sync_ipaddress(200) 76 | self.nb.ip_address.assert_called_once_with(200) 77 | self.nb.prefixes.assert_called_once_with(contains='192.168.0.1/24') 78 | self.kea.set_reservation.assert_has_calls([self.call_resa200]) 79 | 80 | def test_02_sync_ip_address_with_custom_field(self): 81 | self.conn.sync_ipaddress(201) 82 | self.nb.ip_address.assert_called_once_with(201) 83 | self.nb.prefixes.assert_called_once_with(contains='192.168.0.2/24') 84 | self.kea.set_reservation.assert_has_calls([self.call_resa201]) 85 | 86 | def test_03_sync_ip_address_with_assigned_and_custom_field(self): 87 | self.conn.sync_ipaddress(202) 88 | self.nb.ip_address.assert_called_once_with(202) 89 | self.nb.prefixes.assert_called_once_with(contains='192.168.0.3/24') 90 | self.kea.set_reservation.assert_has_calls([self.call_resa202]) 91 | 92 | def test_05_sync_ip_address_vm(self): 93 | self.conn.sync_ipaddress(250) 94 | self.nb.ip_address.assert_called_once_with(250) 95 | self.nb.prefixes.assert_called_once_with(contains='10.0.0.50/8') 96 | self.kea.set_reservation.assert_has_calls([self.call_resa250]) 97 | 98 | def test_09_sync_ip_address_del(self): 99 | self.conn.sync_ipaddress(249) 100 | self.nb.ip_address.assert_called_once_with(249) 101 | self.kea.del_resa.assert_called_once_with(249) 102 | 103 | def test_10_sync_interface(self): 104 | self.conn.sync_interface(300) 105 | self.nb.ip_addresses.assert_called_once_with(interface_id=300) 106 | self.kea.set_reservation.assert_has_calls([self.call_resa200]) 107 | 108 | def test_11_sync_device(self): 109 | self.conn.sync_device(400) 110 | self.nb.ip_addresses.assert_called_once_with(device_id=400) 111 | self.kea.set_reservation.assert_has_calls([self.call_resa200]) 112 | 113 | def test_15_sync_vminterface(self): 114 | self.conn.sync_vminterface(350) 115 | self.nb.ip_addresses.assert_called_once_with(vminterface_id=350) 116 | self.kea.set_reservation.assert_has_calls([self.call_resa250]) 117 | 118 | def test_16_sync_virtualmachine(self): 119 | self.conn.sync_virtualmachine(450) 120 | self.nb.ip_addresses.assert_called_once_with(virtual_machine_id=450) 121 | self.kea.set_reservation.assert_has_calls([self.call_resa250]) 122 | 123 | def test_20_sync_ip_range(self): 124 | self.conn.sync_iprange(250) 125 | self.kea.set_pool.assert_has_calls([self.call_pool250]) 126 | 127 | def test_21_sync_ip_range_del(self): 128 | self.nb.ip_range.return_value = None 129 | self.conn.sync_iprange(299) 130 | self.kea.del_pool.assert_called_once_with(299) 131 | 132 | def test_30_sync_prefix_update(self): 133 | self.conn.sync_prefix(100) 134 | self.kea.update_subnet.assert_called_once_with(100, { 135 | 'subnet': '192.168.0.0/24'}) 136 | 137 | def test_31_sync_prefix_fullsync(self): 138 | self.kea.update_subnet.side_effect = SubnetNotFound() 139 | self.conn.sync_prefix(100) 140 | self.nb.ip_addresses.assert_called_once_with(parent='192.168.0.0/24') 141 | self.nb.ip_ranges.assert_called_once_with(parent='192.168.0.0/24') 142 | self.kea.set_subnet.assert_has_calls([self.call_subnet100]) 143 | self.kea.set_reservation.assert_has_calls( 144 | [self.call_resa200, self.call_resa201, self.call_resa202]) 145 | self.kea.set_pool.assert_has_calls([self.call_pool250]) 146 | 147 | def test_39_sync_prefix_del(self): 148 | self.nb.prefix.return_value = None 149 | self.conn.sync_prefix(199) 150 | self.kea.del_subnet.assert_called_once_with(199) 151 | 152 | def test_99_sync_all(self): 153 | self.conn.sync_all() 154 | self.kea.set_subnet.assert_has_calls([self.call_subnet100]) 155 | self.kea.set_reservation.assert_has_calls( 156 | [self.call_resa200, self.call_resa201, self.call_resa202, 157 | self.call_resa250]) 158 | self.kea.set_pool.assert_has_calls([self.call_pool250]) 159 | self.kea.commit.assert_called() 160 | self.kea.push.assert_called() 161 | -------------------------------------------------------------------------------- /tests/unit/test_kea.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from copy import deepcopy 3 | from unittest.mock import MagicMock, call 4 | 5 | from netboxkea.kea.app import DHCP4App 6 | from netboxkea.kea.exceptions import KeaClientError, SubnetNotFound 7 | 8 | 9 | class TestKea(unittest.TestCase): 10 | 11 | def _set_std_subnet(self): 12 | self.kea.set_subnet(100, {'subnet': '192.168.0.0/24'}) 13 | 14 | def _set_std_resa(self): 15 | self.kea.set_reservation(100, 200, { 16 | 'ip-address': '192.168.0.1', 'hw-address': '11:22:33:44:55:66', 17 | 'hostname': 'pc.lan'}) 18 | 19 | def _set_std_pool(self): 20 | self.kea.set_pool(100, 250, {'pool': '192.168.0.100-192.168.0.199'}) 21 | 22 | def setUp(self): 23 | self.kea = DHCP4App('http://keasrv/api') 24 | self.req = MagicMock() 25 | self.kea.api._request_kea = self.req 26 | self.srv_conf = {'Dhcp4': {}} 27 | self.srv_check_res = True 28 | 29 | def req_result(cmd, params=None): 30 | match cmd: 31 | case 'config-get': 32 | return deepcopy(self.srv_conf) 33 | case 'config-set': 34 | self.srv_conf = deepcopy(params) 35 | case 'config-test': 36 | return self.srv_check_res 37 | case 'config-write': 38 | pass 39 | case _: 40 | raise ValueError(cmd) 41 | 42 | self.req.side_effect = req_result 43 | self.kea.pull() 44 | self.req.reset_mock() 45 | 46 | def test_01_pull(self): 47 | self.assertEqual(self.kea.conf, {'subnet4': []}) 48 | self.assertEqual(self.kea.commit_conf, self.kea.conf) 49 | 50 | def test_02_commit(self): 51 | newconf = {'subnet4': [{'garbage': True}]} 52 | self.kea.conf = deepcopy(newconf) 53 | self.kea.commit() 54 | self.req.assert_called_once_with('config-test', {'Dhcp4': newconf}) 55 | self.assertEqual(self.kea.conf, self.kea.commit_conf) 56 | 57 | def test_03_push_wo_commit(self): 58 | self.kea.push() 59 | self.req.assert_not_called() 60 | 61 | def test_04_push_w_commit(self): 62 | newconf = {'subnet4': [{'garbage': True}]} 63 | self.kea.conf = deepcopy(newconf) 64 | self.kea.commit() 65 | self.kea.push() 66 | calls = [call('config-test', {'Dhcp4': newconf}), 67 | call('config-set', {'Dhcp4': newconf}), 68 | call('config-write', {'Dhcp4': newconf})] 69 | self.req.has_calls(calls) 70 | self.assertEqual(self.srv_conf['Dhcp4'], newconf) 71 | self.assertEqual(self.kea.conf, self.kea.commit_conf) 72 | 73 | def test_10_set_subnet(self): 74 | expected = {'subnet4': [ 75 | {'id': 100, 'subnet': '192.168.0.0/24', 'pools': [], 76 | 'reservations': []}]} 77 | self._set_std_subnet() 78 | self.kea.push() 79 | self.assertEqual(self.srv_conf['Dhcp4'], expected) 80 | 81 | def test_11_set_subnet_replace(self): 82 | self.kea.set_subnet(100, {'subnet': '10.0.0.0/8'}) 83 | self._set_std_subnet() 84 | self.kea.push() 85 | self.assertEqual( 86 | self.srv_conf['Dhcp4']['subnet4'][0]['subnet'], '192.168.0.0/24') 87 | self.assertEqual(len(self.srv_conf['Dhcp4']['subnet4']), 1) 88 | 89 | def test_12_set_subnet_conflict(self): 90 | self._set_std_subnet() 91 | with self.assertRaises(KeaClientError): 92 | self.kea.set_subnet(101, {'subnet': '192.168.0.0/24'}) 93 | 94 | def test_13_update_subnet_notfound(self): 95 | with self.assertRaises(SubnetNotFound): 96 | self.kea.update_subnet(100, {'subnet': '10.0.0.0/8', 'opt': 1}) 97 | 98 | def test_14_update_subnet_ok(self): 99 | self._set_std_subnet() 100 | self.kea.update_subnet(100, {'subnet': '192.168.0.0/24', 'opt': 1}) 101 | self.kea.push() 102 | self.assertEqual(self.srv_conf['Dhcp4']['subnet4'][0]['opt'], 1) 103 | 104 | def test_15_del_subnet(self): 105 | self._set_std_subnet() 106 | self.kea.del_subnet(100) 107 | self.kea.push() 108 | self.assertEqual(len(self.srv_conf['Dhcp4']['subnet4']), 0) 109 | 110 | def test_16_del_all_subnets(self): 111 | self._set_std_subnet() 112 | self.kea.del_all_subnets() 113 | self.kea.push() 114 | self.assertEqual(len(self.srv_conf['Dhcp4']['subnet4']), 0) 115 | 116 | def test_20_set_reservation(self): 117 | expected = {'subnet4': [ 118 | {'id': 100, 'subnet': '192.168.0.0/24', 'pools': [], 119 | 'reservations': [{ 120 | 'ip-address': '192.168.0.1', 'hw-address': '11:22:33:44:55:66', 121 | 'hostname': 'pc.lan', 'user-context': { 122 | 'netbox_ip_address_id': 200}}] 123 | }]} 124 | self._set_std_subnet() 125 | self._set_std_resa() 126 | self.kea.push() 127 | self.assertEqual(self.srv_conf['Dhcp4'], expected) 128 | 129 | def test_21_set_reservation_replace(self): 130 | self._set_std_subnet() 131 | self._set_std_resa() 132 | self.kea.set_reservation(100, 200, { 133 | 'ip-address': '192.168.0.9', 'hw-address': '11:22:33:44:55:66', 134 | 'hostname': 'pc.lan'}) 135 | self.kea.push() 136 | self.assertEqual( 137 | self.srv_conf['Dhcp4']['subnet4'][0]['reservations'][0] 138 | ['ip-address'], '192.168.0.9') 139 | self.assertEqual(len( 140 | self.srv_conf['Dhcp4']['subnet4'][0]['reservations']), 1) 141 | 142 | def test_22_set_reservation_subnet_not_found(self): 143 | with self.assertRaises(SubnetNotFound): 144 | self._set_std_resa() 145 | 146 | def test_23_set_reservation_conflict_hw(self): 147 | self._set_std_subnet() 148 | self._set_std_resa() 149 | with self.assertRaises(KeaClientError): 150 | self.kea.set_reservation(100, 201, { 151 | 'ip-address': '192.168.0.2', 'hw-address': '11:22:33:44:55:66', 152 | 'hostname': 'pc2.lan'}) 153 | 154 | def test_24_set_reservation_conflict_ip(self): 155 | self._set_std_subnet() 156 | self._set_std_resa() 157 | with self.assertRaises(KeaClientError): 158 | self.kea.set_reservation(100, 201, { 159 | 'ip-address': '192.168.0.1', 'hw-address': '11:22:33:33:22:11', 160 | 'hostname': 'pc2.lan'}) 161 | 162 | def test_25_set_reservation_no_conflict_ip(self): 163 | self.srv_conf['Dhcp4']['ip-reservations-unique'] = False 164 | self.kea.pull() 165 | self._set_std_subnet() 166 | self._set_std_resa() 167 | self.kea.set_reservation(100, 201, { 168 | 'ip-address': '192.168.0.1', 'hw-address': '11:22:33:33:22:11', 169 | 'hostname': 'pc2.lan'}) 170 | self.assertEqual(len(self.kea.conf['subnet4'][0]['reservations']), 2) 171 | 172 | def test_26_del_reservation(self): 173 | self._set_std_subnet() 174 | self._set_std_resa() 175 | self.kea.del_resa(200) 176 | self.assertEqual(len(self.kea.conf['subnet4'][0]['reservations']), 0) 177 | 178 | def test_30_set_pool(self): 179 | expected = {'subnet4': [ 180 | {'id': 100, 'subnet': '192.168.0.0/24', 'pools': [{ 181 | 'pool': '192.168.0.100-192.168.0.199', 182 | 'user-context': {'netbox_ip_range_id': 250} 183 | }], 'reservations': []}]} 184 | self._set_std_subnet() 185 | self._set_std_pool() 186 | self.kea.push() 187 | self.assertEqual(self.srv_conf['Dhcp4'], expected) 188 | 189 | def test_33_set_pool_conflict_overlap(self): 190 | self._set_std_subnet() 191 | self._set_std_pool() 192 | with self.assertRaises(KeaClientError): 193 | self.kea.set_pool(100, 251, {'pool': '192.168.0.50-192.168.0.100'}) 194 | with self.assertRaises(KeaClientError): 195 | self.kea.set_pool(100, 251, { 196 | 'pool': '192.168.0.199-192.168.0.250'}) 197 | 198 | def test_35_del_pool(self): 199 | self._set_std_subnet() 200 | self._set_std_pool() 201 | self.kea.del_pool(250) 202 | self.assertEqual(len(self.kea.conf['subnet4'][0]['pools']), 0) 203 | -------------------------------------------------------------------------------- /src/netboxkea/connector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ipaddress import ip_interface 4 | 5 | from .kea.exceptions import (KeaError, KeaClientError, SubnetNotEqual, 6 | SubnetNotFound) 7 | 8 | 9 | def _get_nested(obj, attrs, sep='.'): 10 | """ Get value from a nested list of attributes or keys separated by sep """ 11 | 12 | value = obj 13 | for a in attrs.split(sep): 14 | # getattr must be tried before dict.get because it is able to trigger 15 | # additionnal queries to netbox API. 16 | try: 17 | value = getattr(value, a) 18 | except AttributeError: 19 | value = value[a] 20 | 21 | return value 22 | 23 | 24 | def _set_dhcp_attr(dhcp_item, key, value): 25 | """ 26 | Set value to DHCP item dictionary. Key may be nested keys separated 27 | by dots, in which case each key represents a nested dictionary (or list, if 28 | the parent attribut is known to use a list). 29 | """ 30 | 31 | k1, _, k2 = key.partition('.') 32 | if not k2: 33 | dhcp_item[key] = value 34 | elif k1 in ['option-data']: 35 | # Some keys hold a list of name/data dicts 36 | dhcp_item.setdefault(k1, []).append( 37 | {'name': k2, 'data': value}) 38 | else: 39 | dhcp_item.setdefault(k1, {})[k2] = value 40 | 41 | 42 | def _mk_dhcp_item(nb_obj, mapping): 43 | """ Convert a netbox object to a DHCP dictionary item """ 44 | 45 | dhcp_item = {} 46 | for dhcp_attr, nb_attr in mapping.items(): 47 | # Get value from netbox object 48 | attrs = [nb_attr] if isinstance(nb_attr, str) else nb_attr 49 | # Map value is expected to be list of attributes. The first 50 | # existing and non-null attribute will be used as the DHCP value 51 | value = None 52 | for a in attrs: 53 | try: 54 | value = _get_nested(nb_obj, a) 55 | except (TypeError, KeyError): 56 | continue 57 | if value: 58 | break 59 | 60 | # Set value to DHCP setting 61 | # Kea don’t like None value (TODO even if JSON converts it to "null"?) 62 | if value is not None: 63 | _set_dhcp_attr(dhcp_item, dhcp_attr, value) 64 | 65 | return dhcp_item 66 | 67 | 68 | class Connector: 69 | """ Main class that connects Netbox objects to Kea DHCP config items """ 70 | 71 | def __init__(self, nb, kea, prefix_subnet_map, pool_iprange_map, 72 | reservation_ipaddr_map, check=False): 73 | self.nb = nb 74 | self.kea = kea 75 | self.subnet_prefix_map = prefix_subnet_map 76 | self.pool_iprange_map = pool_iprange_map 77 | self.reservation_ipaddr_map = reservation_ipaddr_map 78 | self.check = check 79 | 80 | def sync_all(self): 81 | """ Replace current DHCP configuration by a new generated one """ 82 | 83 | self.kea.pull() 84 | self.kea.del_all_subnets() 85 | 86 | # Create DHCP configuration for each prefix 87 | all_failed = None 88 | for p in self.nb.all_prefixes(): 89 | if all_failed is None: 90 | all_failed = True 91 | pl = f'prefix {p}: ' 92 | logging.debug(f'{pl}generate DHCP config') 93 | # Speed up things by disabling auto-commit 94 | self.kea.auto_commit = False 95 | try: 96 | self._prefix_to_subnet(p, fullsync=True) 97 | except KeaError as e: 98 | logging.error(f'{pl}config failed. Error: {e}') 99 | continue 100 | 101 | # Make intermediate commits only when not in check mode to avoid 102 | # false errors of missing, not yet created, subnets. 103 | if not self.check: 104 | try: 105 | self.kea.commit() 106 | except KeaError as e: 107 | logging.error(f'{pl}commit failed. Error: {e}') 108 | # Retry with auto-commit enabled to catch the faulty item 109 | logging.warning(f'{pl}retry with auto commit on') 110 | self.kea.auto_commit = True 111 | try: 112 | self._prefix_to_subnet(p, fullsync=True) 113 | except KeaError as e: 114 | logging.error(f'{pl}config failed. Error: {e}') 115 | continue 116 | 117 | all_failed = False 118 | 119 | self.kea.auto_commit = True 120 | if all_failed is not True: 121 | self.push_to_dhcp() 122 | 123 | def push_to_dhcp(self): 124 | if self.check: 125 | logging.info('check mode on: config will NOT be pushed to server') 126 | else: 127 | self.kea.push() 128 | 129 | def reload_dhcp_config(self): 130 | self.kea.pull() 131 | 132 | def sync_prefix(self, id_): 133 | p = self.nb.prefix(id_) 134 | self._prefix_to_subnet(p) if p else self.kea.del_subnet(id_) 135 | 136 | def sync_iprange(self, id_): 137 | r = self.nb.ip_range(id_) 138 | self._iprange_to_pool(r) if r else self.kea.del_pool(id_) 139 | 140 | def sync_ipaddress(self, id_): 141 | i = self.nb.ip_address(id_) 142 | self._ipaddr_to_resa(i) if i else self.kea.del_resa(id_) 143 | 144 | def sync_interface(self, id_): 145 | for i in self.nb.ip_addresses(interface_id=id_): 146 | self.sync_ipaddress(i.id) 147 | 148 | def sync_device(self, id_): 149 | for i in self.nb.ip_addresses(device_id=id_): 150 | self.sync_ipaddress(i.id) 151 | 152 | def sync_vminterface(self, id_): 153 | for i in self.nb.ip_addresses(vminterface_id=id_): 154 | self.sync_ipaddress(i.id) 155 | 156 | def sync_virtualmachine(self, id_): 157 | for i in self.nb.ip_addresses(virtual_machine_id=id_): 158 | self.sync_ipaddress(i.id) 159 | 160 | def _prefix_to_subnet(self, pref, fullsync=False): 161 | subnet = _mk_dhcp_item(pref, self.subnet_prefix_map) 162 | subnet['subnet'] = pref.prefix 163 | if not fullsync: 164 | try: 165 | self.kea.update_subnet(pref.id, subnet) 166 | except (SubnetNotEqual, SubnetNotFound): 167 | # Subnet address has changed or subnet is missing, recreate it 168 | fullsync = True 169 | 170 | if fullsync: 171 | self.kea.set_subnet(pref.id, subnet) 172 | # Add host reservations 173 | for i in self.nb.ip_addresses(parent=pref.prefix): 174 | try: 175 | self._ipaddr_to_resa(i, prefix=pref) 176 | except KeaClientError as e: 177 | logging.error(f'prefix {pref} > IP {i}: {e}') 178 | # Add pools 179 | for r in self.nb.ip_ranges(parent=pref.prefix): 180 | try: 181 | self._iprange_to_pool(r, prefix=pref) 182 | except KeaClientError as e: 183 | logging.error(f'prefix {pref} > range {r}: {e}') 184 | 185 | def _iprange_to_pool(self, iprange, prefix=None): 186 | prefixes = [prefix] if prefix else self.nb.prefixes( 187 | contains=iprange.start_address) 188 | pool = _mk_dhcp_item(iprange, self.pool_iprange_map) 189 | start = str(ip_interface(iprange.start_address).ip) 190 | end = str(ip_interface(iprange.end_address).ip) 191 | pool['pool'] = f'{start}-{end}' 192 | for pref in prefixes: 193 | try: 194 | self.kea.set_pool(pref.id, iprange.id, pool) 195 | except SubnetNotFound: 196 | if not prefix: 197 | logging.warning( 198 | f'subnet {pref.prefix} is missing, sync it again') 199 | self._prefix_to_subnet(pref, fullsync=True) 200 | else: 201 | logging.error(f'requested subnet {pref.prefix} not found') 202 | 203 | def _ipaddr_to_resa(self, ip, prefix=None): 204 | prefixes = [prefix] if prefix else self.nb.prefixes( 205 | contains=ip.address) 206 | resa = _mk_dhcp_item(ip, self.reservation_ipaddr_map) 207 | if not resa.get('hw-address'): 208 | self.kea.del_resa(ip.id) 209 | return 210 | 211 | resa['ip-address'] = str(ip_interface(ip.address).ip) 212 | for pref in prefixes: 213 | try: 214 | self.kea.set_reservation(pref.id, ip.id, resa) 215 | except SubnetNotFound: 216 | if not prefix: 217 | logging.warning( 218 | f'subnet {pref.prefix} is missing, sync it again') 219 | self._prefix_to_subnet(pref) 220 | else: 221 | logging.error(f'requested subnet {pref.prefix} not found') 222 | -------------------------------------------------------------------------------- /src/netboxkea/kea/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from copy import deepcopy 3 | from ipaddress import ip_interface, ip_network 4 | 5 | from .api import DHCP4API, FileAPI 6 | from .exceptions import (DuplicateValue, KeaCmdError, SubnetNotEqual, 7 | SubnetNotFound) 8 | 9 | # Kea configuration keys 10 | SUBNETS = 'subnet4' 11 | USR_CTX = 'user-context' 12 | POOLS = 'pools' 13 | RESAS = 'reservations' 14 | PREFIX = 'id' 15 | IP_RANGE = 'netbox_ip_range_id' 16 | IP_ADDR = 'netbox_ip_address_id' 17 | 18 | 19 | def _autocommit(func): 20 | """ Decorator to autocommit changes after method execution """ 21 | 22 | def wrapper(self, *args, **kwargs): 23 | res = func(self, *args, **kwargs) 24 | commit_arg = kwargs.get('commit') 25 | if commit_arg is True or (commit_arg is None and self.auto_commit): 26 | self.commit() 27 | return res 28 | return wrapper 29 | 30 | 31 | def _boundaries(ip_range): 32 | """ Return a tuple of first and last ip_interface of the pool """ 33 | 34 | # pool may be expressed by a "-" seperated range or by a network 35 | try: 36 | start, end = ip_range.split('-') 37 | except ValueError: 38 | net = ip_network(ip_range) 39 | return ip_interface(net.network_address), ip_interface( 40 | net.broadcast_address) 41 | else: 42 | return ip_interface(start), ip_interface(end) 43 | 44 | 45 | class DHCP4App: 46 | 47 | def __init__(self, url=None): 48 | if url.startswith('http://') or url.startswith('https://'): 49 | self.api = DHCP4API(url) 50 | elif url.startswith('file://'): 51 | self.api = FileAPI(url.removeprefix('file://')) 52 | else: 53 | raise ValueError( 54 | 'Kea URL must starts either with "http(s)://" or "file://"') 55 | self.conf = None 56 | self.commit_conf = None 57 | self._has_commit = False 58 | self.auto_commit = True 59 | 60 | def pull(self): 61 | """ Fetch configuration from DHCP server """ 62 | 63 | logging.info('pull running config from DHCP server') 64 | self.conf = self.api.get_conf() 65 | # Set minimal expected keys 66 | self.conf.setdefault(SUBNETS, []) 67 | for s in self.conf[SUBNETS]: 68 | for r in s.setdefault(RESAS, []): 69 | r.setdefault(USR_CTX, {}).setdefault(IP_ADDR, None) 70 | for p in s.setdefault(POOLS, []): 71 | p.setdefault(USR_CTX, {}).setdefault(IP_RANGE, None) 72 | 73 | self.commit_conf = deepcopy(self.conf) 74 | self.ip_uniqueness = self.conf.get('ip-reservations-unique', True) 75 | 76 | def commit(self): 77 | """ Record changes to the configuration. Return True if success """ 78 | 79 | try: 80 | logging.debug('check configuration') 81 | self.api.raise_conf_error(self.conf) 82 | except KeaCmdError: 83 | # Drop current working config 84 | logging.error('config check failed, drop uncommited changes') 85 | self.conf = deepcopy(self.commit_conf) 86 | raise 87 | else: 88 | logging.debug('commit configuration') 89 | self.commit_conf = deepcopy(self.conf) 90 | self._has_commit = True 91 | return True 92 | 93 | def push(self): 94 | """ Update DHCP server configuration """ 95 | 96 | if self._has_commit: 97 | logging.info('push configuration to runtime DHCP server') 98 | try: 99 | self.api.set_conf(self.commit_conf) 100 | logging.info('write configuration to permanent file') 101 | self.api.write_conf() 102 | except KeaCmdError as e: 103 | logging.error(f'config push or write rejected: {e}') 104 | 105 | self._has_commit = None 106 | else: 107 | logging.debug('no commit to push') 108 | 109 | def _check_commit(self, commit=None): 110 | """ Commit conf if required by argument or instance attribute """ 111 | 112 | if commit is True or (commit is None and self.auto_commit): 113 | self.commit() 114 | 115 | @_autocommit 116 | def set_subnet(self, prefix_id, subnet_item): 117 | """ Replace subnet with prefix ID or append a new one """ 118 | 119 | self._set_subnet(prefix_id, subnet_item, only_update_options=False) 120 | 121 | @_autocommit 122 | def update_subnet(self, prefix_id, subnet_item): 123 | """ 124 | Update subnet options (preserve current reservations and pools). Raise 125 | SubnetNotEqual if network address differs, or SubnetNotFound if no 126 | subnet prefix ID matches. 127 | """ 128 | 129 | self._set_subnet(prefix_id, subnet_item, only_update_options=True) 130 | 131 | def _set_subnet(self, prefix_id, subnet_item, only_update_options): 132 | """ Update subnet options, replace subnet or append a new one """ 133 | 134 | try: 135 | subnet = subnet_item['subnet'] 136 | except KeyError as e: 137 | raise TypeError(f'Missing mandatory subnet key: {e}') 138 | 139 | sfound = None 140 | for s in self.conf[SUBNETS]: 141 | if s[PREFIX] == prefix_id: 142 | sfound = s 143 | if s['subnet'] == subnet: 144 | # No network addr change, no need to inspect other subnets 145 | break 146 | elif only_update_options: 147 | raise SubnetNotEqual(f'subnet {s["subnet"]} ≠ {subnet}') 148 | 149 | # Continue in order to check duplicates 150 | elif s['subnet'] == subnet: 151 | raise DuplicateValue(f'duplicate subnet {subnet}') 152 | 153 | subnet_item[PREFIX] = prefix_id 154 | if sfound: 155 | if only_update_options: 156 | logging.info(f'subnet {subnet}: update with {subnet_item}') 157 | # Preserve reservations and pools 158 | subnet_item[RESAS] = sfound[RESAS] 159 | subnet_item[POOLS] = sfound[POOLS] 160 | else: 161 | subnet_item.setdefault(RESAS, []) 162 | subnet_item.setdefault(POOLS, []) 163 | logging.info(f'subnet ID {prefix_id}: replace with {subnet}') 164 | # Clear current subnet (except reservations and pools) in order to 165 | # drop Kea default options, as they may conflict with our new 166 | # settings (like min/max-valid-lifetime against valid-lifetime). 167 | sfound.clear() 168 | sfound.update(subnet_item) 169 | elif only_update_options: 170 | raise SubnetNotFound(f'subnet ID {prefix_id}') 171 | else: 172 | subnet_item.setdefault(RESAS, []) 173 | subnet_item.setdefault(POOLS, []) 174 | logging.info(f'subnets: add {subnet}, ID {prefix_id}') 175 | self.conf[SUBNETS].append(subnet_item) 176 | 177 | @_autocommit 178 | def del_subnet(self, prefix_id, commit=None): 179 | logging.info(f'subnets: remove subnet {prefix_id} if it exists') 180 | self.conf[SUBNETS] = [ 181 | s for s in self.conf[SUBNETS] if s[PREFIX] != prefix_id] 182 | 183 | @_autocommit 184 | def del_all_subnets(self): 185 | logging.info('delete all current subnets') 186 | self.conf[SUBNETS].clear() 187 | 188 | @_autocommit 189 | def set_pool(self, prefix_id, iprange_id, pool_item): 190 | """ Replace pool or append a new one """ 191 | 192 | try: 193 | start, end = pool_item['pool'].split('-') 194 | except KeyError as e: 195 | raise TypeError(f'Missing mandatory pool key: {e}') 196 | 197 | pool_item.setdefault(USR_CTX, {})[IP_RANGE] = iprange_id 198 | ip_start, ip_end = ip_interface(start), ip_interface(end) 199 | 200 | def raise_conflict(p): 201 | pl = p.get('pool') 202 | if pl: 203 | s, e = _boundaries(pl) 204 | if s <= ip_start <= e or s <= ip_end <= e: 205 | raise DuplicateValue(f'overlaps existing pool {pl}') 206 | 207 | self._set_subnet_item( 208 | prefix_id, POOLS, IP_RANGE, iprange_id, pool_item, raise_conflict, 209 | pool_item['pool']) 210 | 211 | @_autocommit 212 | def del_pool(self, iprange_id): 213 | self._del_prefix_item(POOLS, IP_RANGE, iprange_id) 214 | 215 | @_autocommit 216 | def set_reservation(self, prefix_id, ipaddr_id, resa_item): 217 | """ Replace host reservation or append a new one """ 218 | 219 | for k in ('ip-address', 'hw-address'): 220 | if k not in resa_item: 221 | raise TypeError(f'Missing mandatory reservation key: {k}') 222 | 223 | resa_item.setdefault(USR_CTX, {})[IP_ADDR] = ipaddr_id 224 | 225 | def raise_conflict(r): 226 | if r.get('hw-address') == resa_item['hw-address']: 227 | raise DuplicateValue( 228 | f'duplicate hw-address={r["hw-address"]}') 229 | elif (self.ip_uniqueness and r.get('ip-address') == 230 | resa_item['ip-address']): 231 | raise DuplicateValue(f'duplicate address={r["ip-address"]}') 232 | 233 | self._set_subnet_item( 234 | prefix_id, RESAS, IP_ADDR, ipaddr_id, resa_item, raise_conflict, 235 | resa_item['hw-address']) 236 | 237 | @_autocommit 238 | def del_resa(self, ipaddr_id): 239 | self._del_prefix_item(RESAS, IP_ADDR, ipaddr_id) 240 | 241 | def _set_subnet_item(self, prefix_id, item_list, item_key, item_id, new, 242 | raise_conflict, display): 243 | """ Replace either a pool or a host reservation """ 244 | 245 | for s in self.conf[SUBNETS]: 246 | found = None 247 | if s[PREFIX] == prefix_id: 248 | # Prefix found 249 | for i in s[item_list]: 250 | if i[USR_CTX][item_key] == item_id: 251 | found = i 252 | # Continue in order to check duplicates 253 | else: 254 | raise_conflict(i) 255 | 256 | if found: 257 | logging.info( 258 | f'subnet {prefix_id} > {item_list} > ID {item_id}: ' 259 | f'replace with {display}') 260 | found.clear() 261 | found.update(new) 262 | else: 263 | logging.info( 264 | f'subnet {prefix_id} > {item_list}: add {display}, ' 265 | f'ID {item_id}') 266 | s[item_list].append(new) 267 | break 268 | else: 269 | raise SubnetNotFound(f'subnet {prefix_id}') 270 | 271 | def _del_prefix_item(self, item_list, item_key, item_id): 272 | """ Delete item from all subnets. Silently ignore non-existent item """ 273 | 274 | logging.info(f'{item_list}: delete resa {item_id} if it exists') 275 | for s in self.conf[SUBNETS]: 276 | s[item_list] = [ 277 | i for i in s[item_list] if i[USR_CTX][item_key] != item_id] 278 | --------------------------------------------------------------------------------