blob: e968e8e3391b59c6966d74aa0cb2b8aec9aea125 [file] [log] [blame]
Zack Williamscaf05662020-10-09 19:52:40 -07001#!/usr/bin/env python3
2
3# SPDX-FileCopyrightText: © 2020 Open Networking Foundation <support@opennetworking.org>
4# SPDX-License-Identifier: Apache-2.0
5
6# TODO:
7# Fix issues where IPMI given primary IP for a node
8
9from __future__ import absolute_import
10
11import argparse
12import json
13import logging
14import netaddr
15import re
16import ssl
17import urllib.parse
18import urllib.request
19from ruamel import yaml
20
21# create shared logger
22logging.basicConfig()
23logger = logging.getLogger("nbht")
24
25# global dict of jsonpath expressions -> compiled jsonpath parsers, as
26# reparsing expressions in each loop results in 100x longer execution time
27jpathexpr = {}
28
29# headers to pass, set globally
30headers = []
31
32# settings
33settings = {}
34
35# cached data from API
36devices = {}
37interfaces = {}
38
39
40def parse_nb_args():
41 """
42 parse CLI arguments
43 """
44
45 parser = argparse.ArgumentParser(description="NetBox Host Descriptions")
46
47 # Positional args
48 parser.add_argument(
49 "settings",
50 type=argparse.FileType("r"),
51 help="YAML ansible inventory file w/netbox info",
52 )
53
54 parser.add_argument(
55 "--debug", action="store_true", help="Print additional debugging information"
56 )
57
58 return parser.parse_args()
59
60
61def json_api_get(
62 url,
63 headers,
64 data=None,
65 trim_prefix=False,
66 allow_failure=False,
67 validate_certs=False,
68):
69 """
70 Call JSON API endpoint, return data as a dict
71 """
72
73 logger.debug("json_api_get url: %s", url)
74
75 # if data included, encode it as JSON
76 if data:
77 data_enc = str(json.dumps(data)).encode("utf-8")
78
79 request = urllib.request.Request(url, data=data_enc, method="POST")
80 request.add_header("Content-Type", "application/json; charset=UTF-8")
81 else:
82 request = urllib.request.Request(url)
83
84 # add headers tuples
85 for header in headers:
86 request.add_header(*header)
87
88 try:
89
90 if validate_certs:
91 response = urllib.request.urlopen(request)
92
93 else:
94 ctx = ssl.create_default_context()
95 ctx.check_hostname = False
96 ctx.verify_mode = ssl.CERT_NONE
97
98 response = urllib.request.urlopen(request, context=ctx)
99
100 except urllib.error.HTTPError:
101 # asking for data that doesn't exist results in a 404, just return nothing
102 if allow_failure:
103 return None
104 logger.exception("Server encountered an HTTPError at URL: '%s'", url)
105 except urllib.error.URLError:
106 logger.exception("An URLError occurred at URL: '%s'", url)
107 else:
108 # docs: https://docs.python.org/3/library/json.html
109 jsondata = response.read()
110 logger.debug("API response: %s", jsondata)
111
112 try:
113 data = json.loads(jsondata)
114 except json.decoder.JSONDecodeError:
115 # allow return of no data
116 if allow_failure:
117 return None
118 logger.exception("Unable to decode JSON")
119 else:
120 logger.debug("JSON decoded: %s", data)
121
122 return data
123
124
125def create_dns_zone(extension, devs):
126 # Checks for dns entries
127
128 a_recs = {} # PTR records created by inverting this
129 cname_recs = {}
130 srv_recs = {}
131 ns_recs = []
132 txt_recs = {}
133
134 # scan through devs and look for dns_name, if not, make from name and
135 # extension
136 for name, value in devs.items():
137
138 # add DNS entries for every DHCP host if there's a DHCP range
139 # DHCP addresses are of the form dhcp###.extension
140 if name == "prefix_dhcp":
141 for ip in netaddr.IPNetwork(value["dhcp_range"]).iter_hosts():
142 a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
143
144 continue
145
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800146 # require DNS names to only use ASCII characters
147 # (alphanumeric, lowercase, with dash/period)
Zack Williamscaf05662020-10-09 19:52:40 -0700148 # _'s are used in SRV/TXT records, but in general use aren't recommended
149 dns_name = re.sub("[^a-z0-9.-]", "-", name.lower(), 0, re.ASCII)
150
151 # Add as an A record (and inverse, PTR record), only if it's a new name
152 if dns_name not in a_recs:
153 a_recs[dns_name] = value["ip4"]
154 else:
155 # most likely a data entry error
156 logger.warning(
157 "Duplicate DNS name '%s' for devices at IP: '%s' and '%s', ignoring",
158 dns_name,
159 a_recs[dns_name],
160 value["ip4"],
161 )
162 continue
163
164 # if a DNS name is given as a part of the IP address, it's viewed as a CNAME
165 if value["dns_name"]:
166
167 if re.search("%s$" % extension, value["dns_name"]):
168
169 # strip off the extension, and add as a CNAME
170 dns_cname = value["dns_name"].split(".%s" % extension)[0]
171
172 elif "." in value["dns_name"]:
173 logger.warning(
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800174 "Device '%s' has a IP assigned DNS name '%s' outside "
175 + "the prefix extension: '%s', ignoring",
Zack Williamscaf05662020-10-09 19:52:40 -0700176 name,
177 value["dns_name"],
178 extension,
179 )
180 continue
181
182 else:
183 dns_cname = value["dns_name"]
184
185 if dns_cname == dns_name:
186 logger.warning(
187 "DNS Name field '%s' is identical to device name '%s', ignoring",
188 value["dns_name"],
189 dns_name,
190 )
191 else:
192 cname_recs[dns_cname] = "%s.%s." % (dns_name, extension)
193
194 # Add services as cnames, and possibly ns records
195 for svc in value["services"]:
196
197 # only add service if it uses the IP of the host
198 if value["ip4"] in svc["ip4s"]:
199 cname_recs[svc["name"]] = "%s.%s." % (dns_name, extension)
200
201 if svc["port"] == 53 and svc["protocol"] == "udp":
202 ns_recs.append("%s.%s." % (dns_name, extension))
203
204 return {
205 "a": a_recs,
206 "cname": cname_recs,
207 "ns": ns_recs,
208 "srv": srv_recs,
209 "txt": txt_recs,
210 }
211
212
213def create_dhcp_subnet(devs):
214 # makes DHCP subnet information
215
216 hosts = {}
217
218 for name, value in devs.items():
219
220 # has a MAC address, and it's not null
221 if "macaddr" in value and value["macaddr"]:
222
223 hosts[value["ip4"]] = {
224 "name": name,
225 "macaddr": value["macaddr"],
226 }
227
228 return hosts
229
230
231def get_device_services(device_id, filters=""):
232
233 # get services info
234 url = "%s%s" % (
235 settings["api_endpoint"],
236 "api/ipam/services/?device_id=%s%s" % (device_id, filters),
237 )
238
239 raw_svcs = json_api_get(url, headers, validate_certs=settings["validate_certs"])
240
241 services = []
242
243 for rsvc in raw_svcs["results"]:
244
245 svc = {}
246
247 svc["name"] = rsvc["name"]
248 svc["description"] = rsvc["description"]
249 svc["port"] = rsvc["port"]
250 svc["protocol"] = rsvc["protocol"]["value"]
251 svc["ip4s"] = []
252
253 for ip in rsvc["ipaddresses"]:
254 svc["ip4s"].append(str(netaddr.IPNetwork(ip["address"]).ip))
255
256 services.append(svc)
257
258 return services
259
260
261def get_interface_mac_addr(interface_id):
262 # return a mac addres, or None if undefined
263
264 # get the interface info
265 url = "%s%s" % (settings["api_endpoint"], "api/dcim/interfaces/%s/" % interface_id)
266
267 iface = json_api_get(url, headers, validate_certs=settings["validate_certs"])
268
269 if iface["mac_address"]:
270 return iface["mac_address"]
271
272 return None
273
274
275def get_device_interfaces(device_id, filters=""):
276
277 url = "%s%s" % (
278 settings["api_endpoint"],
279 "api/dcim/interfaces/?device_id=%s%s" % (device_id, filters),
280 )
281
282 logger.debug("raw_ifaces_url: %s", url)
283
284 raw_ifaces = json_api_get(url, headers, validate_certs=settings["validate_certs"])
285
286 logger.debug("raw_ifaces: %s", raw_ifaces)
287
288 ifaces = []
289
290 for raw_iface in raw_ifaces["results"]:
291
292 iface = {}
293
294 iface["name"] = raw_iface["name"]
295 iface["macaddr"] = raw_iface["mac_address"]
296 iface["mgmt_only"] = raw_iface["mgmt_only"]
297 iface["description"] = raw_iface["description"]
298
299 if raw_iface["count_ipaddresses"]:
300 url = "%s%s" % (
301 settings["api_endpoint"],
302 "api/ipam/ip-addresses/?interface_id=%s" % raw_iface["id"],
303 )
304
305 raw_ip = json_api_get(
306 url, headers, validate_certs=settings["validate_certs"]
307 )
308
309 iface["ip4"] = str(netaddr.IPNetwork(raw_ip["results"][0]["address"]).ip)
310
311 ifaces.append(iface)
312
313 return ifaces
314
315
316def get_prefix_devices(prefix, filters=""):
317
318 # get all devices in a prefix
319 url = "%s%s" % (
320 settings["api_endpoint"],
321 "api/ipam/ip-addresses/?parent=%s%s" % (prefix, filters),
322 )
323
324 raw_ips = json_api_get(url, headers, validate_certs=settings["validate_certs"])
325
326 logger.debug("raw_ips: %s", raw_ips)
327
328 devs = {}
329
330 for ip in raw_ips["results"]:
331
332 logger.info("ip: %s", ip)
333
334 # if it's a DHCP range, add that range to the dev list as prefix_dhcp
335 if ip["status"]["value"] == "dhcp":
336 devs["prefix_dhcp"] = {"dhcp_range": ip["address"]}
337 continue
338
339 dev = {}
340
341 dev["ip4"] = str(netaddr.IPNetwork(ip["address"]).ip)
342 dev["macaddr"] = get_interface_mac_addr(ip["assigned_object"]["id"])
343
344 ifaces = get_device_interfaces(
345 ip["assigned_object"]["device"]["id"], "&mgmt_only=true"
346 )
347
348 if ifaces and dev["ip4"] == ifaces[0]["ip4"]: # this is a mgmt IP
349 devname = "%s-%s" % (
350 ip["assigned_object"]["device"]["name"],
351 ifaces[0]["name"],
352 )
353 dev["dns_name"] = ""
354 dev["services"] = []
355
356 else: # this is a primary IP
357
358 devname = ip["assigned_object"]["device"]["name"]
359 dev["dns_name"] = ip["dns_name"] if "dns_name" in ip else "None"
360 dev["services"] = get_device_services(ip["assigned_object"]["device"]["id"])
361
362 devs[devname] = dev
363
364 return devs
365
366
367def get_prefix_data(prefix):
368
369 # get all devices in a prefix
370 url = "%s%s" % (settings["api_endpoint"], "api/ipam/prefixes/?prefix=%s" % prefix)
371
372 raw_prefix = json_api_get(url, headers, validate_certs=settings["validate_certs"])
373
374 logger.debug("raw_prefix: %s", raw_prefix)
375
376 return raw_prefix["results"][0]
377
378
379# main function that calls other functions
380if __name__ == "__main__":
381
382 args = parse_nb_args()
383
384 # only print log messages if debugging
385 if args.debug:
386 logger.setLevel(logging.DEBUG)
387 else:
388 logger.setLevel(logging.INFO)
389
390 # load settings from yaml file
391 settings = yaml.safe_load(args.settings.read())
392
393 logger.info("settings: %s" % settings)
394
395 # global, so this isn't run multiple times
396 headers = [
397 ("Authorization", "Token %s" % settings["token"]),
398 ]
399
400 # create structure from extracted data
401
402 dns_global = {}
403 dns_zones = {}
404 dhcp_global = {}
405 dhcp_subnets = {}
406
407 for prefix in settings["dns_prefixes"]:
408
409 prefix_data = get_prefix_data(prefix)
410
411 prefix_domain_extension = prefix_data["description"]
412
413 devs = get_prefix_devices(prefix)
414
415 dns_zones[prefix_domain_extension] = create_dns_zone(
416 prefix_domain_extension, devs
417 )
418
419 dns_zones[prefix_domain_extension]["ip_range"] = prefix
420
421 dhcp_subnets[prefix] = create_dhcp_subnet(devs)
422
423 yaml_out = {
424 "dns_global": dns_global,
425 "dns_zones": dns_zones,
426 "dhcp_global": dhcp_global,
427 "dhcp_subnets": dhcp_subnets,
428 "devs": devs,
429 "prefix_data": prefix_data,
430 }
431
432 print(yaml.safe_dump(yaml_out, indent=2))