#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# Univention Management Console
#  module server process implementation
#
# Copyright 2006-2022 Univention GmbH
#
# https://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <https://www.gnu.org/licenses/>.
"""This module provides a class for an UMC module server. it is based on
the UMC server class
:class:`~univention.management.console.protocol.server.Server`.
"""
import errno
import sys
import traceback
import socket
import notifier
import six
from .server import Server
from .message import Response, Message, IncompleteMessageError, ParseError
from .definitions import MODULE_ERR_INIT_FAILED, SUCCESS, RECV_BUFFER_SIZE
from univention.management.console.acl import ACLs
from univention.management.console.module import Module
from univention.management.console.log import MODULE, PROTOCOL
from univention.lib.i18n import Translation
try:
	from typing import Any, NoReturn, Optional  # noqa: F401
except ImportError:
	pass
_ = Translation('univention.management.console').translate
[docs]class ModuleServer(Server):
	"""Implements an UMC module server
	:param str socket: UNIX socket filename
	:param str module: name of the UMC module to serve
	:param int timeout: If there are no incoming requests for *timeout* seconds the module server shuts down
	:param bool check_acls: if False the module server does not check the permissions (**dangerous!**)
	"""
	def __init__(self, socket, module, timeout=300, check_acls=True):
		# type: (str, str, int, bool) -> None
		self.__name = module
		self.__module = module
		self.__commands = Module()
		self.__comm = None
		self.__client = None
		self.__buffer = b''
		self.__acls = None
		self.__timeout = timeout
		self.__time_remaining = timeout
		self.__active_requests = 0
		self._timer()
		self.__check_acls = check_acls
		self.__queue = b''
		self.__username = None
		self.__user_dn = None
		self.__password = None
		self.__init_etype = None
		self.__init_exc = None
		self.__init_etraceback = None
		self.__handler = None
		self._load_module()
		Server.__init__(self, port=None, ssl=False, unix=socket, magic=False, load_ressources=False)
		MODULE.process('Module socket initialized.')
	def __enter__(self):
		x = super(ModuleServer, self).__enter__()
		self.signal_connect('session_new', self._client)
		return x
	def _load_module(self):
		# type: () -> None
		MODULE.process('Loading python module.')
		modname = self.__module
		from ..error import UMC_Error
		try:
			try:
				file_ = 'univention.management.console.modules.%s' % (modname,)
				self.__module = __import__(file_, {}, {}, modname)
				MODULE.process('Imported python module.')
				self.__handler = self.__module.Instance()
				MODULE.process('Module instance created.')
			except Exception as exc:
				error = _('Failed to load module %(module)s: %(error)s\n%(traceback)s') % {'module': modname, 'error': exc, 'traceback': traceback.format_exc()}
				MODULE.error(error)
				if isinstance(exc, ImportError) and str(exc).startswith('No module named %s' % (modname,)):
					error = '\n'.join((
						_('The requested module %r does not exist.') % (modname,),
						_('The module may have been removed recently.'),
						_('Please relogin to the Univention Management Console to see if the error persists.'),
						_('Further information can be found in the logfile %s.') % ('/var/log/univention/management-console-module-%s.log' % (modname,),),
					))
				raise UMC_Error(error, status=MODULE_ERR_INIT_FAILED)
		except UMC_Error:
			try:
				exc_info = sys.exc_info()
				self.__init_etype, self.__init_exc, self.__init_etraceback = exc_info  # FIXME: do not keep a reference to traceback
			finally:
				exc_info = None
		else:
			self.__handler.signal_connect('success', notifier.Callback(self._reply, True))
	def _reply(self, msg, final):
		if final:
			self.__active_requests -= 1
		self.response(msg)
	def _timer(self):
		# type: () -> None
		"""In order to avoid problems when the system time is changed (e.g.,
		via rdate), we register a timer event that counts down the session
		timeout second-wise."""
		# count down the remaining time
		if not self.__active_requests:
			self.__time_remaining -= 1
		if self.__time_remaining <= 0:
			# module has timed out
			self._timed_out()
		else:
			# count down the timer second-wise (in order to avoid problems when
			# changing the system time, e.g. via rdate)
			notifier.timer_add(1000, self._timer)
	def _timed_out(self):
		# type: () -> NoReturn
		MODULE.info('Committing suicide')
		if self.__handler:
			self.__handler.destroy()
		self.exit()
		sys.exit(0)
	def _client(self, client, socket):
		self.__comm = socket
		self.__client = client
		notifier.socket_add(self.__comm, self._recv)
	def _recv(self, sock):
		# type: (socket.socket) -> bool
		try:
			data = sock.recv(RECV_BUFFER_SIZE)
		except socket.error as exc:
			MODULE.error('Failed connection: %s' % (errno.errorcode.get(exc.errno, exc.errno),))
			data = None
		# connection closed?
		if not data:
			sock.close()
			if sock == self.__comm:
				MODULE.info('UMC server connection closed. This module is no longer in use.')
				# the connection to UMC server connection has been closed/died/...
				# so from now on this module is unused. Thus it is committing suicide right now.
				self._timed_out()
			else:
				MODULE.info('Connection %r closed' % (sock,))
			# remove socket from notifier
			return False
		self.__buffer += data
		msg = None
		while self.__buffer:
			try:
				msg = Message()
				self.__buffer = msg.parse(self.__buffer)
				MODULE.info("Received request %s" % msg.id)
				self.handle(msg)
			except IncompleteMessageError:
				MODULE.info('Failed to parse incomplete message')
				return True
			except ParseError as exc:
				MODULE.error('Failed to parse message: %s' % (exc,))
				if not msg.id:
					msg.id = -1
				status, message = exc.args
				from ..error import UMC_Error
				raise UMC_Error(message, status=status)
			except Exception:
				self.error_handling(msg, 'init', *sys.exc_info())
		return True
[docs]	def error_handling(self, request, method, etype, exc, etraceback):
		if self.__handler:
			self.__handler._Base__requests[request.id] = (request, method)
			self.__handler._Base__error_handling(request, method, etype, exc, etraceback)
			return
		trace = ''.join(traceback.format_exception(etype, exc, etraceback))
		MODULE.error('The init function of the module failed\n%s: %s' % (exc, trace,))
		from ..error import UMC_Error
		if not isinstance(exc, UMC_Error):
			error = _('The initialization of the module failed: %s') % (trace,)
			exc = UMC_Error(error, status=MODULE_ERR_INIT_FAILED)
			etype = UMC_Error
		resp = Response(request)
		resp.status = exc.status
		resp.message = str(exc)
		resp.result = exc.result
		resp.headers = exc.headers
		self.response(resp) 
[docs]	def handle(self, msg):
		# type: (Request) -> None
		"""Handles incoming UMCP requests. This function is called only
		when it is a valid UMCP request.
		:param Request msg: the received UMCP request
		The following commands are handled directly and are not passed
		to the custom module code:
		* SET (acls|username|credentials)
		* EXIT
		"""
		from ..error import UMC_Error, NotAcceptable
		self.__time_remaining = self.__timeout
		PROTOCOL.info('Received UMCP %s REQUEST %s' % (msg.command, msg.id))
		resp = Response(msg)
		resp.status = SUCCESS
		if msg.command == 'EXIT':
			shutdown_timeout = 100
			MODULE.info("EXIT: module shutdown in %dms" % shutdown_timeout)
			# shutdown module after one second
			resp.message = 'module %s will shutdown in %dms' % (msg.arguments[0], shutdown_timeout)
			self.response(resp)
			notifier.timer_add(shutdown_timeout, self._timed_out)
			return
		if self.__init_etype:
			notifier.timer_add(10000, self._timed_out)
			six.reraise(self.__init_etype, self.__init_exc, self.__init_etraceback)
		if msg.command == 'SET':
			for key, value in msg.options.items():
				if key == 'acls':
					self.__acls = ACLs(acls=value)
					self.__handler.acls = self.__acls
				elif key == 'commands':
					self.__commands.fromJSON(value['commands'])
				elif key == 'username':
					self.__username = value
					self.__handler.username = self.__username
				elif key == 'password':
					self.__password = value
					self.__handler.password = self.__password
				elif key == 'auth_type':
					self.__auth_type = value
					self.__handler.auth_type = self.__auth_type
				elif key == 'credentials':
					self.__username = value['username']
					self.__user_dn = value['user_dn']
					self.__password = value['password']
					self.__auth_type = value.get('auth_type')
					self.__handler.username = self.__username
					self.__handler.user_dn = self.__user_dn
					self.__handler.password = self.__password
					self.__handler.auth_type = self.__auth_type
				elif key == 'locale' and value is not None:
					try:
						self.__handler.update_language([value])
					except NotAcceptable:
						pass  # ignore if the locale doesn't exists, it continues with locale C
				else:
					raise UMC_Error(status=422)
			# if SET command contains 'acls', commands' and
			# 'credentials' it is the initialization of the module
			# process
			if 'acls' in msg.options and 'commands' in msg.options and 'credentials' in msg.options:
				MODULE.process('Initializing module.')
				try:
					self.__handler.init()
				except Exception:
					try:
						exc_info = sys.exc_info()
						self.__init_etype, self.__init_exc, self.__init_etraceback = exc_info  # FIXME: do not keep a reference to traceback
						self.error_handling(msg, 'init', *exc_info)
					finally:
						exc_info = None
					return
			self.response(resp)
			return
		if msg.arguments:
			cmd = msg.arguments[0]
			cmd_obj = self.command_get(cmd)
			if cmd_obj and (not self.__check_acls or self.__acls.is_command_allowed(cmd, options=msg.options, flavor=msg.flavor)):
				self.__active_requests += 1
				self.__handler.execute(cmd_obj.method, msg)
				return
			raise UMC_Error('Not initialized.', status=403) 
[docs]	def command_get(self, command_name):
		# type: (str) -> Optional[Any]
		"""Returns the command object that matches the given command name"""
		for cmd in self.__commands.commands:
			if cmd.name == command_name:
				return cmd
		return None 
[docs]	def command_is_known(self, command_name):
		# type: (str) -> bool
		"""Checks if a command with the given command name is known
		:rtype: bool
		"""
		for cmd in self.__commands.commands:
			if cmd.name == command_name:
				return True
		return False 
	def _do_send(self, sock):
		if len(self.__queue) > 0:
			length = len(self.__queue)
			try:
				ret = self.__comm.send(self.__queue)
			except socket.error as exc:
				if exc.errno == errno.EWOULDBLOCK:
					return True
				if exc.errno == errno.EPIPE:
					return False
				raise
			if ret < length:
				self.__queue = self.__queue[ret:]
				return True
			else:
				self.__queue = b''
				return False
		else:
			return False
[docs]	def response(self, msg):
		"""Sends an UMCP response to the client"""
		PROTOCOL.info('Sending UMCP RESPONSE %s' % msg.id)
		self.__queue += bytes(msg)
		if self._do_send(self.__comm):
			notifier.socket_add(self.__comm, self._do_send, notifier.IO_WRITE)