├── .gitignore ├── LICENSE ├── README.md ├── config.py.example ├── netbox-powerdns-sync.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | config.py 2 | venv/ 3 | env/ 4 | __pycache__/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexander Votteler 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # netbox-powerdns-sync 2 | 3 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 4 | 5 | **netbox-powerdns-sync** is an open-source project that provides a Python script which puts DNS entries from NetBox into PowerDNS. 6 | 7 | ## Installation 8 | 9 | Download this repository and nstall the dependencies: 10 | 11 | ```bash 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ## PowerDNS preperation 16 | 17 | Make sure all zones (forward and reverse) already exist in PowerDNS. 18 | 19 | ## Configuration 20 | 21 | Create a `config.py` file like this: 22 | 23 | ```python 24 | # URL of your NetBox installation 25 | NB_URL = "https://netbox.local/" 26 | # NetBox API token (read-only is sufficent) 27 | NB_TOKEN = "" 28 | 29 | # URL of your PowerDNS API endpoint 30 | PDNS_API_URL = "http://powerdns.local:8081/api/v1" 31 | # PowerDNS API key 32 | PDNS_KEY = "" 33 | 34 | # Forward Zones that should be matched in NetBox for import into PowerDNS 35 | FORWARD_ZONES = ["local"] 36 | # Prefixes that from NetBox with corresponding .arpa Zones for import into PowerDNS 37 | REVERSE_ZONES = [{"zone": "10.10.10.in-addr.arpa.", "prefix": "10.10.10/24"}] 38 | 39 | # Only create reverse pointers for NetBox IP objects that have a matching custom field value 40 | PTR_ONLY_CF = False 41 | 42 | # Only output changes to CLI, do not change PowerDNS 43 | DRY_RUN = True 44 | 45 | # Use IP dns_name as source for sync 46 | SOURCE_IP = True 47 | # Use device name as source for sync 48 | SOURCE_DEVICE = True 49 | # Use VM name as source for sync 50 | SOURCE_VM = True 51 | ``` 52 | 53 | ## Execution 54 | 55 | ```bash 56 | python netbox-powerdns-sync.py 57 | ``` 58 | 59 | ## Contribution 60 | 61 | We welcome contributions and suggestions! If you find a bug or want to add a feature, please create an issue or a pull request. 62 | 63 | ## License 64 | 65 | This project is licensed under the MIT License - see the LICENSE file for details. 66 | -------------------------------------------------------------------------------- /config.py.example: -------------------------------------------------------------------------------- 1 | NB_URL = "https://netbox.local/" 2 | NB_TOKEN = "" 3 | 4 | PDNS_API_URL = "http://powerdns.local:8081/api/v1" 5 | PDNS_KEY = "" 6 | 7 | FORWARD_ZONES = ["local"] 8 | REVERSE_ZONES = [{"zone": "10.10.10.in-addr.arpa.", "prefix": "10.10.10/24"}] 9 | 10 | PTR_ONLY_CF = False 11 | 12 | DRY_RUN = True 13 | 14 | SOURCE_IP = True 15 | SOURCE_DEVICE = True 16 | SOURCE_VM = True 17 | -------------------------------------------------------------------------------- /netbox-powerdns-sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import ipaddress 5 | import logging 6 | import re 7 | import sys 8 | from collections import Counter 9 | 10 | import powerdns 11 | import pynetbox 12 | from systemd.journal import JournalHandler 13 | 14 | from config import DRY_RUN, FORWARD_ZONES, REVERSE_ZONES 15 | from config import NB_TOKEN, NB_URL, PDNS_API_URL, PDNS_KEY 16 | from config import PTR_ONLY_CF 17 | from config import SOURCE_DEVICE, SOURCE_IP, SOURCE_VM 18 | 19 | 20 | def make_canonical(zone): 21 | # return a zone in canonical form 22 | return f'{zone}.' 23 | 24 | 25 | def get_host_ips_ip(nb, zone): 26 | # return list of tuples for ip addresses 27 | host_ips = [] 28 | 29 | # get IPs with DNS name ending in forward_zone from NetBox 30 | if PTR_ONLY_CF: 31 | nb_ips = nb.ipam.ip_addresses.filter( 32 | dns_name__iew=zone, 33 | status=['active', 'dhcp', 'slaac'], 34 | cf_ptr_only=False 35 | ) 36 | else: 37 | nb_ips = nb.ipam.ip_addresses.filter( 38 | dns_name__iew=zone, 39 | status=['active', 'dhcp', 'slaac'] 40 | ) 41 | 42 | # assemble list with tupels containing the canonical name, the record 43 | # type and the IP address without the subnet from NetBox IPs 44 | for nb_ip in nb_ips: 45 | nb_zone = nb_ip.dns_name.split('.') 46 | if zone != '.'.join(nb_zone[1:]): 47 | continue 48 | 49 | if nb_ip.family.value == 6: 50 | type = 'AAAA' 51 | else: 52 | type = 'A' 53 | 54 | host_ips.append(( 55 | make_canonical(nb_ip.dns_name), 56 | type, 57 | re.sub('/[0-9]*', '', str(nb_ip)), 58 | make_canonical(zone) 59 | )) 60 | 61 | return host_ips 62 | 63 | 64 | def get_host_ips_ip_reverse(nb, prefix, zone): 65 | # return list of reverse zone tupels for ip addresses 66 | host_ips = [] 67 | 68 | # get IPs within the prefix from NetBox 69 | nb_ips = nb.ipam.ip_addresses.filter( 70 | parent=prefix, 71 | status=['active', 'dhcp', 'slaac'] 72 | ) 73 | 74 | # assemble list with tupels containing the canonical name, the record type 75 | # and the IP address without the subnet from NetBox IPs 76 | for nb_ip in nb_ips: 77 | if nb_ip.dns_name != '': 78 | ip = re.sub('/[0-9]*', '', str(nb_ip)) 79 | reverse_pointer = ipaddress.ip_address(ip).reverse_pointer 80 | host_ips.append(( 81 | make_canonical(reverse_pointer), 82 | 'PTR', 83 | make_canonical(nb_ip.dns_name), 84 | make_canonical(zone) 85 | )) 86 | 87 | return host_ips 88 | 89 | 90 | def get_host_ips_device(nb, zone): 91 | # return list of tupels for devices 92 | # get devices with name ending in forward_zone from NetBox 93 | nb_devices = nb.dcim.devices.filter( 94 | name__iew=zone, 95 | status=['active', 'failed', 'offline', 'staged'] 96 | ) 97 | 98 | return get_host_ips_host(nb_devices, zone) 99 | 100 | 101 | def get_host_ips_vm(nb, zone): 102 | # return list of tupels for VMs 103 | # get VMs with name ending in forward_zone from NetBox 104 | nb_vms = nb.virtualization.virtual_machines.filter( 105 | name__iew=zone, 106 | status=['active', 107 | 'failed', 108 | 'offline', 109 | 'staged']) 110 | 111 | return get_host_ips_host(nb_vms, zone) 112 | 113 | 114 | def get_host_ips_host(nb_hosts, zone): 115 | # return list of tupels for hosts (NetBox devices/VMs) 116 | host_ips = [] 117 | 118 | # assemble list with tupels containing the canonical name, the record 119 | # type and the IP addresses without the subnet of the device/vm 120 | for nb_host in nb_hosts: 121 | if nb_host.primary_ip4: 122 | host_ips.append(( 123 | make_canonical(nb_host.name), 124 | 'A', 125 | re.sub('/[0-9]*', '', str(nb_host.primary_ip4)), 126 | make_canonical(zone) 127 | )) 128 | 129 | if nb_host.primary_ip6: 130 | host_ips.append(( 131 | make_canonical(nb_host.name), 132 | 'AAAA', 133 | re.sub('/[0-9]*', '', str(nb_host.primary_ip6)), 134 | make_canonical(zone) 135 | )) 136 | 137 | return host_ips 138 | 139 | 140 | def main(): 141 | parser = argparse.ArgumentParser( 142 | description='Sync DNS name entries from NetBox to PowerDNS', 143 | epilog='''This script uses the REST API of NetBox to retriev 144 | IP addresses and their DNS name. It then syncs the DNS names 145 | to PowerDNS to create A, AAAA and PTR records. 146 | It does this for forward and reverse zones specified in the config 147 | file. 148 | ''') 149 | parser.add_argument('--dry_run', '-d', action='store_true', 150 | help='Perform a dry run (make no changes to PowerDNS)') 151 | parser.add_argument('--loglevel', '-l', type=str, default='INFO', 152 | choices=['WARNING', 'INFO', ''], 153 | help='Log level for the console logger') 154 | parser.add_argument('--loglevel_journal', '-j', type=str, default='', 155 | choices=['WARNING', 'INFO', ''], 156 | help='Log level for the systemd journal logger') 157 | args = parser.parse_args() 158 | 159 | # merge dry_run directives from config and arguments 160 | dry_run = False 161 | if args.dry_run or DRY_RUN: 162 | dry_run = True 163 | 164 | logger = logging.getLogger(__name__) 165 | # set overall log level to debug to catch all 166 | logger.setLevel(logging.DEBUG) 167 | # loglevel for console logging 168 | if args.loglevel != '': 169 | handler = logging.StreamHandler() 170 | handler.setLevel(getattr(logging, args.loglevel)) 171 | formatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') 172 | handler.setFormatter(formatter) 173 | logger.addHandler(handler) 174 | 175 | # loglevel for journal logging 176 | if args.loglevel_journal != '': 177 | journal_handler = JournalHandler() 178 | journal_handler.setLevel(getattr(logging, args.loglevel_journal)) 179 | logger.addHandler(journal_handler) 180 | 181 | nb = pynetbox.api(NB_URL, token=NB_TOKEN) 182 | 183 | pdns_api_client = powerdns.PDNSApiClient(api_endpoint=PDNS_API_URL, 184 | api_key=PDNS_KEY) 185 | pdns = powerdns.PDNSEndpoint(pdns_api_client).servers[0] 186 | 187 | host_ips = [] 188 | record_ips = [] 189 | 190 | for forward_zone in FORWARD_ZONES: 191 | # Source IP: Create domains based on DNS name attached to IPs 192 | if SOURCE_IP: 193 | host_ips += get_host_ips_ip(nb, forward_zone) 194 | # Source device: Create domains based on the name of devices 195 | if SOURCE_DEVICE: 196 | host_ips += get_host_ips_device(nb, forward_zone) 197 | # Source VM: Create domains based on the name of VMs 198 | if SOURCE_VM: 199 | host_ips += get_host_ips_vm(nb, forward_zone) 200 | 201 | # get zone forward_zone_canonical form PowerDNS 202 | zone = pdns.get_zone(make_canonical(forward_zone)) 203 | 204 | if zone is None: 205 | logger.critical(f'Zone {forward_zone} not found in PowerDNS. Skipping it.') 206 | continue 207 | 208 | # assemble list with tupels containing the canonical name, the record 209 | # type, the IP address and forward_zone_canonical without the subnet 210 | # from PowerDNS zone records with the 211 | # comment 'NetBox' 212 | for record in zone.records: 213 | for comment in record['comments']: 214 | if comment['content'] == 'NetBox': 215 | for ip in record['records']: 216 | record_ips.append(( 217 | record['name'], 218 | record['type'], 219 | ip['content'], 220 | make_canonical(forward_zone) 221 | )) 222 | 223 | for reverse_zone in REVERSE_ZONES: 224 | host_ips += get_host_ips_ip_reverse(nb, reverse_zone['prefix'], 225 | reverse_zone['zone']) 226 | 227 | # get reverse zone records form PowerDNS 228 | zone = pdns.get_zone(make_canonical(reverse_zone['zone'])) 229 | 230 | if zone is None: 231 | logger.critical(f'Zone {reverse_zone["zone"]} not found in PowerDNS. Skipping it.') 232 | continue 233 | 234 | # assemble list with tupels containing the canonical name, the record 235 | # type, the IP address and forward_zone_canonical without the subnet 236 | # from PowerDNS zone records with the 237 | # comment 'NetBox' 238 | for record in zone.records: 239 | for comment in record['comments']: 240 | if comment['content'] == 'NetBox': 241 | for ip in record['records']: 242 | record_ips.append(( 243 | record['name'], 244 | record['type'], 245 | ip['content'], 246 | make_canonical(reverse_zone['zone']) 247 | )) 248 | 249 | # find duplicates in host_ips 250 | duplicate_records = [(host_ip[0], host_ip[1]) for host_ip in host_ips] 251 | duplicate_records = [duplicate for duplicate, amount in 252 | Counter(duplicate_records).items() if amount > 1] 253 | for duplicate_record in duplicate_records: 254 | logger.critical(f'''Detected duplicate record from NetBox \ 255 | {duplicate_record[0]} of type {duplicate_record[1]}. 256 | Not continuing execution. Please resolve the duplicate.''') 257 | if len(duplicate_records) > 0: 258 | sys.exit() 259 | 260 | # create set with tupels that have to be created 261 | # tupels from NetBox without tupels that already exists in PowerDNS 262 | to_create = set(host_ips) - set(record_ips) 263 | 264 | # create set with tupels that have to be deleted 265 | # tupels from PowerDNS without tupels that are documented in NetBox 266 | to_delete = set(record_ips) - set(host_ips) 267 | 268 | logger.info(f'{len(to_create)} records to create') 269 | for record in to_create: 270 | logger.info(f'Will create record {record[0]}') 271 | 272 | logger.info(f'{len(to_delete)} records to delete') 273 | for record in to_delete: 274 | logger.info(f'Will delete record {record[0]}') 275 | 276 | if dry_run: 277 | logger.info('Skipping Create/Delete due to Dry Run') 278 | sys.exit() 279 | 280 | for record in to_create: 281 | logger.info(f'Now creating {record}') 282 | zone = pdns.get_zone(record[3]) 283 | zone.create_records([ 284 | powerdns.RRSet( 285 | record[0], 286 | record[1], 287 | [(record[2], False)], 288 | comments=[powerdns.Comment('NetBox')]) 289 | ]) 290 | 291 | for record in to_delete: 292 | logger.info(f'Now deleting {record}') 293 | zone = pdns.get_zone(record[3]) 294 | zone.delete_records([ 295 | powerdns.RRSet( 296 | record[0], 297 | record[1], 298 | [(record[2], False)], 299 | comments=[powerdns.Comment('NetBox')]) 300 | ]) 301 | 302 | 303 | if __name__ == '__main__': 304 | main() 305 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pynetbox>=6.1.3 2 | python_powerdns>=2.0.0 3 | systemd-python>=235 4 | --------------------------------------------------------------------------------