blob: 9e085f8d6679a2e06bf58d7b8e83353b495a217a [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
146 # require DNS names to only use ASCII characters (alphanumeric, lowercase, with dash/period)
147 # _'s are used in SRV/TXT records, but in general use aren't recommended
148 dns_name = re.sub("[^a-z0-9.-]", "-", name.lower(), 0, re.ASCII)
149
150 # Add as an A record (and inverse, PTR record), only if it's a new name
151 if dns_name not in a_recs:
152 a_recs[dns_name] = value["ip4"]
153 else:
154 # most likely a data entry error
155 logger.warning(
156 "Duplicate DNS name '%s' for devices at IP: '%s' and '%s', ignoring",
157 dns_name,
158 a_recs[dns_name],
159 value["ip4"],
160 )
161 continue
162
163 # if a DNS name is given as a part of the IP address, it's viewed as a CNAME
164 if value["dns_name"]:
165
166 if re.search("%s$" % extension, value["dns_name"]):
167
168 # strip off the extension, and add as a CNAME
169 dns_cname = value["dns_name"].split(".%s" % extension)[0]
170
171 elif "." in value["dns_name"]:
172 logger.warning(
173 "Device '%s' has a IP assigned DNS name '%s' outside the prefix extension: '%s', ignoring",
174 name,
175 value["dns_name"],
176 extension,
177 )
178 continue
179
180 else:
181 dns_cname = value["dns_name"]
182
183 if dns_cname == dns_name:
184 logger.warning(
185 "DNS Name field '%s' is identical to device name '%s', ignoring",
186 value["dns_name"],
187 dns_name,
188 )
189 else:
190 cname_recs[dns_cname] = "%s.%s." % (dns_name, extension)
191
192 # Add services as cnames, and possibly ns records
193 for svc in value["services"]:
194
195 # only add service if it uses the IP of the host
196 if value["ip4"] in svc["ip4s"]:
197 cname_recs[svc["name"]] = "%s.%s." % (dns_name, extension)
198
199 if svc["port"] == 53 and svc["protocol"] == "udp":
200 ns_recs.append("%s.%s." % (dns_name, extension))
201
202 return {
203 "a": a_recs,
204 "cname": cname_recs,
205 "ns": ns_recs,
206 "srv": srv_recs,
207 "txt": txt_recs,
208 }
209
210
211def create_dhcp_subnet(devs):
212 # makes DHCP subnet information
213
214 hosts = {}
215
216 for name, value in devs.items():
217
218 # has a MAC address, and it's not null
219 if "macaddr" in value and value["macaddr"]:
220
221 hosts[value["ip4"]] = {
222 "name": name,
223 "macaddr": value["macaddr"],
224 }
225
226 return hosts
227
228
229def get_device_services(device_id, filters=""):
230
231 # get services info
232 url = "%s%s" % (
233 settings["api_endpoint"],
234 "api/ipam/services/?device_id=%s%s" % (device_id, filters),
235 )
236
237 raw_svcs = json_api_get(url, headers, validate_certs=settings["validate_certs"])
238
239 services = []
240
241 for rsvc in raw_svcs["results"]:
242
243 svc = {}
244
245 svc["name"] = rsvc["name"]
246 svc["description"] = rsvc["description"]
247 svc["port"] = rsvc["port"]
248 svc["protocol"] = rsvc["protocol"]["value"]
249 svc["ip4s"] = []
250
251 for ip in rsvc["ipaddresses"]:
252 svc["ip4s"].append(str(netaddr.IPNetwork(ip["address"]).ip))
253
254 services.append(svc)
255
256 return services
257
258
259def get_interface_mac_addr(interface_id):
260 # return a mac addres, or None if undefined
261
262 # get the interface info
263 url = "%s%s" % (settings["api_endpoint"], "api/dcim/interfaces/%s/" % interface_id)
264
265 iface = json_api_get(url, headers, validate_certs=settings["validate_certs"])
266
267 if iface["mac_address"]:
268 return iface["mac_address"]
269
270 return None
271
272
273def get_device_interfaces(device_id, filters=""):
274
275 url = "%s%s" % (
276 settings["api_endpoint"],
277 "api/dcim/interfaces/?device_id=%s%s" % (device_id, filters),
278 )
279
280 logger.debug("raw_ifaces_url: %s", url)
281
282 raw_ifaces = json_api_get(url, headers, validate_certs=settings["validate_certs"])
283
284 logger.debug("raw_ifaces: %s", raw_ifaces)
285
286 ifaces = []
287
288 for raw_iface in raw_ifaces["results"]:
289
290 iface = {}
291
292 iface["name"] = raw_iface["name"]
293 iface["macaddr"] = raw_iface["mac_address"]
294 iface["mgmt_only"] = raw_iface["mgmt_only"]
295 iface["description"] = raw_iface["description"]
296
297 if raw_iface["count_ipaddresses"]:
298 url = "%s%s" % (
299 settings["api_endpoint"],
300 "api/ipam/ip-addresses/?interface_id=%s" % raw_iface["id"],
301 )
302
303 raw_ip = json_api_get(
304 url, headers, validate_certs=settings["validate_certs"]
305 )
306
307 iface["ip4"] = str(netaddr.IPNetwork(raw_ip["results"][0]["address"]).ip)
308
309 ifaces.append(iface)
310
311 return ifaces
312
313
314def get_prefix_devices(prefix, filters=""):
315
316 # get all devices in a prefix
317 url = "%s%s" % (
318 settings["api_endpoint"],
319 "api/ipam/ip-addresses/?parent=%s%s" % (prefix, filters),
320 )
321
322 raw_ips = json_api_get(url, headers, validate_certs=settings["validate_certs"])
323
324 logger.debug("raw_ips: %s", raw_ips)
325
326 devs = {}
327
328 for ip in raw_ips["results"]:
329
330 logger.info("ip: %s", ip)
331
332 # if it's a DHCP range, add that range to the dev list as prefix_dhcp
333 if ip["status"]["value"] == "dhcp":
334 devs["prefix_dhcp"] = {"dhcp_range": ip["address"]}
335 continue
336
337 dev = {}
338
339 dev["ip4"] = str(netaddr.IPNetwork(ip["address"]).ip)
340 dev["macaddr"] = get_interface_mac_addr(ip["assigned_object"]["id"])
341
342 ifaces = get_device_interfaces(
343 ip["assigned_object"]["device"]["id"], "&mgmt_only=true"
344 )
345
346 if ifaces and dev["ip4"] == ifaces[0]["ip4"]: # this is a mgmt IP
347 devname = "%s-%s" % (
348 ip["assigned_object"]["device"]["name"],
349 ifaces[0]["name"],
350 )
351 dev["dns_name"] = ""
352 dev["services"] = []
353
354 else: # this is a primary IP
355
356 devname = ip["assigned_object"]["device"]["name"]
357 dev["dns_name"] = ip["dns_name"] if "dns_name" in ip else "None"
358 dev["services"] = get_device_services(ip["assigned_object"]["device"]["id"])
359
360 devs[devname] = dev
361
362 return devs
363
364
365def get_prefix_data(prefix):
366
367 # get all devices in a prefix
368 url = "%s%s" % (settings["api_endpoint"], "api/ipam/prefixes/?prefix=%s" % prefix)
369
370 raw_prefix = json_api_get(url, headers, validate_certs=settings["validate_certs"])
371
372 logger.debug("raw_prefix: %s", raw_prefix)
373
374 return raw_prefix["results"][0]
375
376
377# main function that calls other functions
378if __name__ == "__main__":
379
380 args = parse_nb_args()
381
382 # only print log messages if debugging
383 if args.debug:
384 logger.setLevel(logging.DEBUG)
385 else:
386 logger.setLevel(logging.INFO)
387
388 # load settings from yaml file
389 settings = yaml.safe_load(args.settings.read())
390
391 logger.info("settings: %s" % settings)
392
393 # global, so this isn't run multiple times
394 headers = [
395 ("Authorization", "Token %s" % settings["token"]),
396 ]
397
398 # create structure from extracted data
399
400 dns_global = {}
401 dns_zones = {}
402 dhcp_global = {}
403 dhcp_subnets = {}
404
405 for prefix in settings["dns_prefixes"]:
406
407 prefix_data = get_prefix_data(prefix)
408
409 prefix_domain_extension = prefix_data["description"]
410
411 devs = get_prefix_devices(prefix)
412
413 dns_zones[prefix_domain_extension] = create_dns_zone(
414 prefix_domain_extension, devs
415 )
416
417 dns_zones[prefix_domain_extension]["ip_range"] = prefix
418
419 dhcp_subnets[prefix] = create_dhcp_subnet(devs)
420
421 yaml_out = {
422 "dns_global": dns_global,
423 "dns_zones": dns_zones,
424 "dhcp_global": dhcp_global,
425 "dhcp_subnets": dhcp_subnets,
426 "devs": devs,
427 "prefix_data": prefix_data,
428 }
429
430 print(yaml.safe_dump(yaml_out, indent=2))