#! /usr/bin/python3

# $Id: infoblox2radiator 48196 2019-06-19 10:43:55Z jhoeke $
# $URL: https://svn.uvt.nl/its-id/trunk/sources/infoblox2radiator/infoblox2radiator $

import pycurl
from io import BytesIO
from sys import stdout, stderr, argv
from base64 import b64encode
from json import loads as json_loads, dump as json_dump
from urllib.parse import urlencode
from re import compile as regcomp
from os import fsync, rename

config = {}

if len(argv) > 1:
	with open(argv[1]) as fh:
		exec(fh.read(), {}, config)
elif len(argv) > 2:
	raise Exception("usage: %s [configuration file]" % (argv[0],))

api_url = config.get('api_url', 'https://ddi.tst-campus.uvt.nl/wapi/v2.9')
api_username = config.get('api_username', 'admin')
api_password = config.get('api_password', 'infoblox')
max_host_connections = config.get('max_host_connections')

filename_mac = config.get('filename_mac', 'rad_users-mac')
filename_phones = config.get('filename_phones', 'rad_users-phones')

min_entries_mac = config.get('min_entries_mac', 5000)
min_entries_phones = config.get('min_entries_phones', 500)

def dump(j):
	"""Dump a JSON object to stderr."""

	json_dump(j, stderr, indent = "\t")
	print('', file = stderr)

class APIClient:
	def __init__(self, url, username = None, password = None, max_host_connections = None):
		self._url = url
		multi = pycurl.CurlMulti()
		multi.setopt(pycurl.M_PIPELINING, pycurl.PIPE_MULTIPLEX)
		if max_host_connections is not None:
			multi.setopt(pycurl.M_MAX_HOST_CONNECTIONS, max_host_connections)
		self._max_host_connections = max_host_connections

		headers = ['Cache-Control: no-cache']
		if username is not None:
			userbytes = username.encode()
			passbytes = password.encode()
			authorization = b64encode(userbytes + b":" + passbytes).decode()
			headers.append('Authorization: Basic ' + authorization)

		self._headers = headers
		self._multi = multi
		self._active = set()

	def _get(self, callback, method, parameters = None):
		url = self._url + method
		if parameters is not None:
			url += ('&' if '?' in url else '?') + urlencode(parameters)

		c = pycurl.Curl()
		c.setopt(pycurl.URL, url)
		c.setopt(pycurl.HTTP_VERSION, pycurl.CURL_HTTP_VERSION_2)
		c.setopt(pycurl.NOSIGNAL, True)
		c.setopt(pycurl.HTTPHEADER, self._headers)

		c._url = url

		b = BytesIO()
		c.setopt(pycurl.WRITEDATA, b)
		c._bytes = b

		c._callback = callback

		self._multi.add_handle(c)
		self._active.add(c)

	def get(self, callback, method, parameters = None):
		def whendone(self, c, buf = None):
			if buf is None:
				raise Exception(c.errstr())
			code = c.getinfo(pycurl.RESPONSE_CODE)
			if code >= 400:
				raise Exception("HTTP request failed with code %d" % (code,))
			if code < 200 or code >= 300:
				raise Exception("don't know how to handle HTTP code %d" % (code,))
			callback(self, json_loads(buf.decode()))

		self._get(whendone, method, parameters)

	def paged_get(self, callback, method, parameters = {}):
		def whendone(self, res):
			callback(self, res['result'])
			try:
				next_page_id = res['next_page_id']
			except KeyError:
				pass
			else:
				self.get(whendone, method, dict(parameters, _page_id = next_page_id))

		self.get(whendone, method, dict(parameters, _max_results = 1000, _paging = 1, _return_as_object = 1))

	def wait(self):
		multi = self._multi
		active = self._active
		while True:
			while True:
				ret, num_handles = multi.perform()
				if ret != pycurl.E_CALL_MULTI_PERFORM:
					break

			num_messages, completed, failed = multi.info_read()

			for c in completed:
				#print(c._url, file = stderr)

				# kan pas vanaf buster:
				#if self._max_host_connections not in {1} and c.getinfo(pycurl.HTTP_VERSION) == pycurl.HTTP_VERSION_2_0:
				#	print("HTTP2 detected: setting max host connections to 1", file = stderr)
				#	multi.setopt(pycurl.M_MAX_HOST_CONNECTIONS, 1)

				multi.remove_handle(c)
				active.remove(c)
				c._callback(self, c, c._bytes.getvalue())

			for c, error_num, error_str in failed:
				multi.remove_handle(c)
				active.remove(c)
				c._callback(self, c)

			if not active:
				break

			while multi.select(1.0) == -1:
				pass

# To filter out non-hex-characters from MAC addresses:
nonhex_re = regcomp('[^0-9a-f]')

# Some extattrs have internal syntax: foo | bar | baz
netsplit_re = regcomp('\s*\|\s*')

# To test if something is a phone:
phone_purpose = {'Telefonie'}

# This is the syntax of the mac output file (with an extra newline at the top):
mac_stanza = '''
# %s
%s
	Tunnel-Private-Group-ID = "%s",
	Session-Timeout = "%s"'''

# This is the syntax of the phones output file (with an extra newline at the top):
phones_stanza = '''
# %s
%s
'''

api = APIClient(api_url, api_username, api_password, max_host_connections)

with open(filename_mac + '.new', 'w') as fh_mac, open(filename_phones + '.new', 'w') as fh_phones:
	print("#BEGIN_OF_FILE", file = fh_mac)
	print("#BEGIN_OF_FILE", file = fh_phones)

	networks = []

	def whendone(api, result):
		for net in result:
			try:
				name = net['network']

				# Convert extattrs to something more convenient:
				extattrs = {}
				for ext_name, ext_data in net['extattrs'].items():
					extattrs[ext_name] = ext_data['value']

				try:
					radiator = extattrs['UvT Radiator']
					purpose = extattrs['UvT Netwerkfunctie']
				except KeyError:
					continue

				if radiator != 'FALSE':
					vlan = extattrs['VLAN']

					# TRUE | 43200
					enabled, timeout = netsplit_re.split(radiator)

					networks.append({'name': name, 'vlan': vlan, 'timeout': timeout, 'purpose': purpose})

			except Exception as e:
				try:
					name = net['network']
				except Exception:
					name = "<unknown network>"
				print(name, type(e).__name__, str(e), file = stderr)

	api.get(whendone, '/network', {'_return_fields+': 'extattrs'})

	# Make sure networks is filled before we continue:
	api.wait()

	# Largest networks first:
	networks.sort(key = lambda net: int(net['name'].split('/')[1]))

	entries_mac = 0
	entries_phones = 0

	for net in networks:
		def wrapper(net):
			# Nested function to scope the 'net' variable correctly

			name = net['name']
			vlan = net['vlan']
			timeout = net['timeout']
			purpose = net['purpose']

			# Now that we got those properly scoped, the real callback:
			def whendone(api, result):
				global entries_mac, entries_phones
				for rec in result:
					try:
						mac = rec['mac_address']
						stripped_mac = nonhex_re.sub('', mac.lower())

						if purpose in phone_purpose:
							entries_phones += 1
							print(phones_stanza % (rec['ip_address'], stripped_mac), file = fh_phones)
						else:
							entries_mac += 1
							print(mac_stanza % (rec['ip_address'], stripped_mac, vlan, timeout), file = fh_mac)
					except Exception as e:
						try:
							addr = rec['ip_address']
						except Exception:
							addr = "<unknown address>"
						print(addr, type(e).__name__, str(e), file = stderr)

			return whendone

		api.paged_get(wrapper(net), '/ipv4address', {'network': net['name'], 'mac_address~': '^.'})

	api.wait()

	if entries_mac < min_entries_mac:
		raise Exception("too few mac entries (%d < %d)" % (entries_mac, min_entries_mac))

	if entries_phones < min_entries_phones:
		raise Exception("too few phone entries (%d < %d)" % (entries_phones, min_entries_phones))

	print("\n#END_OF_FILE", file = fh_mac)
	print("\n#END_OF_FILE", file = fh_phones)

	# Zorg dat het ding volledig en veilig op disk staat voor we renamen:
	fh_mac.flush()
	fsync(fh_mac.fileno())

	fh_phones.flush()
	fsync(fh_phones.fileno())

# Vervang de oude file pas als we weten dat alles goed is gegaan.
rename(filename_mac + '.new', filename_mac)
rename(filename_phones + '.new', filename_phones)
