Source code for univention.management.console.protocol.client

# -*- coding: utf-8 -*-
#
# Univention Management Console
#  UMCP client 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/>.

"""Provides a class :class:`.Client` that implements an UMCP client"""
from __future__ import print_function

import errno
import os
import socket
import fcntl

from univention.lib.i18n import Translation

from .message import Request, Response, IncompleteMessageError, ParseError
from .definitions import RECV_BUFFER_SIZE, BAD_REQUEST_AUTH_FAILED, SUCCESS, status_description
from ..log import CORE, PROTOCOL
from ..config import ucr
from OpenSSL import SSL

import notifier
import notifier.signals as signals

try:
	from typing import Any, Dict, Iterable, List, Optional  # noqa: F401
except ImportError:
	pass


[docs]class UnknownRequestError(Exception): pass
[docs]class NoSocketError(Exception): pass
[docs]class ConnectionError(Exception): pass
''' General client class for connecting to a UMC server. Provides basic functionality for session-handling, authentication and request handling. '''
[docs]class Client(signals.Provider, Translation): """Implements an UMCP client :param str servername: hostname of the UMC server to connect to :param int port: port number of the UMC server :param str unix: filename of the UNIX socket to connect to :param bool ssl: if True the connection is encrypted :param bool auth: if False no authentication is required for the connection """ def __verify_cert_cb(self, conn, cert, errnum, depth, ok): # type: (Any, Any, Any, Any, Any) -> Any CORE.info('__verify_cert_cb: Got certificate subject: %s' % cert.get_subject()) CORE.info('__verify_cert_cb: Got certificate issuer: %s' % cert.get_issuer()) CORE.info('__verify_cert_cb: errnum=%d depth=%d ok=%d' % (errnum, depth, ok)) if depth == 0 and ok == 0: response = Response() response.status = BAD_REQUEST_AUTH_FAILED response.message = 'SSL verification error' self.signal_emit('authenticated', False, response) return ok def __init__(self, servername='localhost', port=6670, unix=None, ssl=True): # type: (str, int, Optional[str], bool) -> None '''Initialize a socket-connection to the server.''' signals.Provider.__init__(self) self.__authenticated = False self.__auth_ids = [] # type: List[Any] self.__ssl = ssl self.__unix = unix if self.__ssl and not self.__unix: self.__crypto_context = SSL.Context(SSL.TLSv1_METHOD) self.__crypto_context.set_cipher_list(ucr.get('umc/server/ssl/ciphers', 'DEFAULT')) self.__crypto_context.set_options(SSL.OP_NO_SSLv2) self.__crypto_context.set_options(SSL.OP_NO_SSLv3) self.__crypto_context.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, self.__verify_cert_cb) try: self.__crypto_context.load_verify_locations(os.path.join('/etc/univention/ssl/ucsCA', 'CAcert.pem')) except SSL.Error as exc: # SSL is not possible CORE.process('Client: Setting up SSL configuration failed: %s' % (exc,)) CORE.process('Client: Communication will not be encrypted!') self.__crypto_context = None self.__ssl = False self.__port = port self.__server = servername self.__resend_queue = {} # type: Dict[Any, Any] self.__realsocket = self.__socket = None # type: Optional[socket.socket] self._init_socket() self.__buffer = b'' self.__unfinishedRequests = {} # type: Dict[int, Request] self.signal_new('response') self.signal_new('authenticated') self.signal_new('error') self.signal_new('closed') self.signal_connect('closed', self.__closed) @property def openRequests(self): # type: () -> Iterable[int] """Returns a list of open UMCP requests""" return self.__unfinishedRequests.keys() def __nonzero__(self): # type: () -> bool if self.__ssl and not self.__crypto_context: return False return True def _init_socket(self): # type: () -> None if self.__unix: self.__realsocket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) else: self.__realsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.__realsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) fcntl.fcntl(self.__realsocket.fileno(), fcntl.F_SETFD, 1) if self.__ssl and not self.__unix: self.__socket = SSL.Connection(self.__crypto_context, self.__realsocket) else: if self.__socket: notifier.socket_remove(self.__socket) self.__socket.close() self.__socket = None
[docs] def disconnect(self, force=True): # type: (bool) -> bool """Shutdown the connection. If there are still open requests and *force* is False the connection is kept.""" if not force and self.__unfinishedRequests: return False self.signal_emit('closed') return True
[docs] def connect(self): # type: () -> None """Connects to the UMC server""" if not self.__realsocket and not self.__socket: self._init_socket() assert self.__realsocket try: if self.__ssl and not self.__unix: assert self.__socket self.__socket.connect((self.__server, self.__port)) self.__socket.setblocking(0) try: self.__socket.set_connect_state() notifier.socket_add(self.__socket, self._recv) CORE.info('Client.connect: SSL connection established') except SSL.Error as exc: CORE.process('Client: Setting up SSL configuration failed: %s' % (exc,)) self.__realsocket.shutdown(socket.SHUT_RDWR) self.__realsocket.close() self.__reconnect_without_ssl() notifier.socket_add(self.__realsocket, self._recv) else: if self.__unix: self.__realsocket.connect(self.__unix) else: self.__realsocket.connect((self.__server, self.__port)) self.__realsocket.setblocking(0) notifier.socket_add(self.__realsocket, self._recv) except socket.error as exc: # ENOENT: file not found, ECONNREFUSED: connection refused if exc.errno in (errno.ENOENT, errno.ECONNREFUSED): raise NoSocketError() raise
def _resend(self, sock): # type: (socket.socket) -> bool while self.__resend_queue.get(sock): data = bytes(self.__resend_queue[sock][0]) try: bytessent = sock.send(data) if bytessent < len(data): # only sent part of message self.__resend_queue[sock][0] = data[bytessent:] return True else: del self.__resend_queue[sock][0] except socket.error as exc: if exc.errno in (errno.ECONNABORTED, errno.EISCONN, errno.ENOEXEC, errno.EBADF, errno.EPIPE, errno.ECONNRESET): # Error may happen if module process died and server tries to send request at the same time # ECONNABORTED: connection reset by peer # EISCONN: socket not connected # ENOEXEC: bad file descriptor (?) # EBADF: bad file descriptor # EPIPE: broken pipe # ECONNRESET: Connection reset by peer CORE.info('Client: _resend: socket is damaged: %s' % str(exc)) self.signal_emit('closed') return False if exc.errno in (errno.ENOTCONN, errno.EAGAIN): # EAGAIN: Resource temporarily unavailable # ENOTCONN: socket not connected return True raise except (SSL.WantReadError, SSL.WantWriteError, SSL.WantX509LookupError): return True except SSL.Error as sslexc: CORE.process('Client: Sending via SSL connection failed: %s' % (sslexc,)) save = self.__resend_queue.pop(self.__socket, None) try: if self.__realsocket: self.__realsocket.shutdown(socket.SHUT_RDWR) self.__realsocket.close() except socket.error as exc: CORE.process('Client: could not shutdown socket (not yet connected): %s' % (exc,)) try: self.__reconnect_without_ssl() except socket.error as exc: CORE.info('Client: reconnecting failed: %s' % (exc,)) # [Errno 111] Connection refused if exc.errno in (errno.ENOENT, errno.ECONNREFUSED): self.signal_emit('closed') return False raise if save is not None: self.__resend_queue[self.__realsocket] = save notifier.socket_add(self.__realsocket, self._recv) notifier.socket_add(self.__realsocket, self._resend, notifier.IO_WRITE) return False if sock in self.__resend_queue and not self.__resend_queue[sock]: del self.__resend_queue[sock] return False def __reconnect_without_ssl(self): # type: () -> None CORE.process('Client: Communication will not be encrypted!') self.__ssl = False self._init_socket() assert self.__realsocket self.__realsocket.connect((self.__server, self.__port)) self.__realsocket.setblocking(0) CORE.info('Client.connect: connection established')
[docs] def request(self, msg): # type: (Request) -> None """Sends an UMCP request to the UMC server :param Request msg: the UMCP request to send """ PROTOCOL.info('Sending UMCP %s REQUEST %s' % (msg.command, msg.id)) if self.__ssl and not self.__unix: sock = self.__socket else: sock = self.__realsocket if msg.command == 'AUTH': self.__auth_ids.append(msg.id) self.__resend_queue.setdefault(sock, []).append(bytes(msg)) if self._resend(sock): notifier.socket_add(sock, self._resend, notifier.IO_WRITE) self.__unfinishedRequests[msg.id] = msg
[docs] def invalidate_all_requests(self, status=500, message=None): # type: (int, Request) -> None """Checks for open UMCP requests and invalidates these by faking a response with the given status code""" if self.__unfinishedRequests: CORE.warn('Invalidating all pending requests %s' % ', '.join(self.__unfinishedRequests.keys())) else: CORE.info('No pending requests found') for req in self.__unfinishedRequests.values(): response = Response(req) response.status = status response.message = message self.signal_emit('response', response) self.__unfinishedRequests = {}
def _recv(self, sock): # type: (socket.socket) -> bool try: recv = b'' while True: recv += sock.recv(RECV_BUFFER_SIZE) if self.__ssl and not self.__unix: if not sock.pending(): break else: break except socket.error as exc: CORE.warn('Client: _recv: error on socket: %s' % (exc,)) recv = b'' except SSL.SysCallError: # lost connection or any other unfixable error recv = b'' except SSL.Error: error = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) # lost connection: UMC daemon died probably if error == errno.EPIPE: recv = b'' else: return True if not recv: self.signal_emit('closed') try: sock.close() except Exception: pass notifier.socket_remove(sock) return False if self.__buffer: recv = self.__buffer + recv self.__buffer = b'' try: while recv: response = Response() recv = response.parse(recv) self._handle(response) except IncompleteMessageError: self.__buffer = recv # waiting for the rest except ParseError as exc: CORE.warn('Client: _recv: error parsing message: %s' % (exc,)) self.signal_emit('error', exc) return True def _handle(self, response): # type: (Response) -> None PROTOCOL.info('Received UMCP RESPONSE %s' % response.id) if response.command == 'AUTH' and response.id in self.__auth_ids: self.__authenticated = response.status == SUCCESS self.__unfinishedRequests.pop(response.id) if not self.__authenticated: response.message = response.message or status_description(response.status) self.signal_emit('authenticated', self.__authenticated, response) elif response.id in self.__unfinishedRequests: self.signal_emit('response', response) self.__unfinishedRequests.pop(response.id) else: CORE.warn('Client: _handle: received an unknown response: %s' % (response.id,)) self.signal_emit('error', UnknownRequestError(500, 'Received an unknown response.'))
[docs] def authenticate(self, msg): # type: (Request) -> None """Authenticate against the UMC server""" if msg.command != 'AUTH': raise TypeError('Must be AUTH command!') self.request(msg)
def __closed(self): # type: () -> None for socket_ in (self.__realsocket, self.__socket): if socket_: notifier.socket_remove(socket_) try: socket_.close() except IOError: pass self.__realsocket = None self.__socket = None
if __name__ == '__main__': from getpass import getpass from six.moves import input notifier.init(notifier.GENERIC) def auth_cb(success, response): print('authentication', success, response.status, response.message) client = Client() client.signal_connect('authenticated', auth_cb) client.connect() authRequest = Request('AUTH') authRequest.body['username'] = input('Username: ') authRequest.body['password'] = getpass() client.authenticate(authRequest) notifier.loop()