1#   BAREOS - Backup Archiving REcovery Open Sourced
2#
3#   Copyright (C) 2018-2020 Bareos GmbH & Co. KG
4#
5#   This program is Free Software; you can redistribute it and/or
6#   modify it under the terms of version three of the GNU Affero General Public
7#   License as published by the Free Software Foundation and included
8#   in the file LICENSE.
9#
10#   This program is distributed in the hope that it will be useful, but
11#   WITHOUT ANY WARRANTY; without even the implied warranty of
12#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13#   Affero General Public License for more details.
14#
15#   You should have received a copy of the GNU Affero General Public License
16#   along with this program; if not, write to the Free Software
17#   Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
18#   02110-1301, USA.
19
20"""
21Low Level socket methods to communication with the bareos-director.
22"""
23
24# Authentication code is taken from
25# https://github.com/hanxiangduo/bacula-console-python
26
27import hashlib
28import hmac
29import logging
30import random
31import re
32from select import select
33import socket
34import ssl
35import struct
36import sys
37import time
38import warnings
39
40from bareos.bsock.constants import Constants
41from bareos.bsock.connectiontype import ConnectionType
42from bareos.bsock.protocolmessageids import ProtocolMessageIds
43from bareos.bsock.protocolmessages import ProtocolMessages
44from bareos.bsock.protocolversions import ProtocolVersions
45from bareos.util.bareosbase64 import BareosBase64
46from bareos.util.password import Password
47import bareos.exceptions
48
49# Try to load the sslpsk module,
50# with implement TLS-PSK (Transport Layer Security - Pre-Shared-Key)
51# on top of the ssl module.
52# If it is not available, we continue anyway,
53# but don't use TLS-PSK.
54try:
55    import sslpsk
56except ImportError:
57    warnings.warn(
58        u"Connection encryption via TLS-PSK is not available, as the module sslpsk is not installed."
59    )
60
61
62class LowLevel(object):
63    """
64    Low Level socket methods to communicate with the bareos-director.
65    """
66
67    @staticmethod
68    def argparser_get_bareos_parameter(args):
69        """
70        This method is usally used together with the method argparser_add_default_command_line_arguments.
71
72        @param args: Arguments retrieved by ArgumentParser.parse_args()
73        @type args:  ArgParser.Namespace
74
75        @return: returns the relevant parameter from args to initialize a connection.
76        @rtype: dict
77        """
78        result = {}
79        for key, value in vars(args).items():
80            if value is not None:
81                if key.startswith("BAREOS_"):
82                    bareoskey = key.split("BAREOS_", 1)[1]
83                    result[bareoskey] = value
84        return result
85
86    def __init__(self):
87        self.logger = logging.getLogger()
88        self.logger.debug("init")
89        self.status = None
90        self.address = None
91        self.password = None
92        self.pam_username = None
93        self.pam_password = None
94        self.port = None
95        self.dirname = None
96        self.socket = None
97        self.auth_credentials_valid = False
98        self.max_reconnects = 0
99        self.tls_psk_enable = True
100        self.tls_psk_require = False
101        try:
102            self.tls_version = ssl.PROTOCOL_TLS
103        except AttributeError:
104            self.tls_version = ssl.PROTOCOL_SSLv23
105        self.connection_type = None
106        self.requested_protocol_version = None
107        self.protocol_messages = ProtocolMessages()
108        # identity_prefix have to be set in each class
109        self.identity_prefix = u"R_NONE"
110        self.receive_buffer = b""
111
112    def __del__(self):
113        self.close()
114
115    def connect(
116        self, address, port, dirname, connection_type, name=None, password=None
117    ):
118        self.address = address
119        self.port = int(port)
120        if dirname:
121            self.dirname = dirname
122        else:
123            self.dirname = address
124        self.connection_type = connection_type
125        self.name = name
126        if password is None:
127            raise bareos.exceptions.ConnectionError(u"Parameter 'password' is required.")
128        if isinstance(password, Password):
129            self.password = password
130        else:
131            self.password = Password(password)
132
133        return self.__connect()
134
135    def __connect(self):
136        connected = False
137        connected_plain = False
138        auth = False
139        if self.tls_psk_require:
140            if not self.is_tls_psk_available():
141                raise bareos.exceptions.ConnectionError(
142                    u"TLS-PSK is required, but sslpsk module not loaded/available."
143                )
144            if not self.tls_psk_enable:
145                raise bareos.exceptions.ConnectionError(
146                    u"TLS-PSK is required, but not enabled."
147                )
148
149        if self.tls_psk_enable and self.is_tls_psk_available():
150            try:
151                self.__connect_tls_psk()
152            except (bareos.exceptions.ConnectionError, ssl.SSLError) as e:
153                self._handleSocketError(e)
154                if self.tls_psk_require:
155                    raise
156                else:
157                    self.logger.warning(
158                        u"Failed to connect via TLS-PSK. Trying plain connection."
159                    )
160            else:
161                connected = True
162                self.logger.debug("Encryption: {0}".format(self.socket.cipher()))
163
164        if not connected:
165            self.__connect_plain()
166            connected = True
167            connected_plain = True
168            self.logger.debug("Encryption: None")
169
170        if connected:
171            try:
172                auth = self.auth()
173            except bareos.exceptions.PamAuthenticationError:
174                raise
175            except bareos.exceptions.AuthenticationError:
176                if (
177                    self.connection_type == ConnectionType.DIRECTOR
178                    and self.requested_protocol_version is None
179                    and self.get_protocol_version() > ProtocolVersions.bareos_12_4
180                ):
181                    # reconnect and try old protocol
182                    self.logger.warning(
183                        "Failed to connect using protocol version {0}. Trying protocol version {1}. ".format(
184                            self.get_protocol_version(), ProtocolVersions.bareos_12_4
185                        )
186                    )
187                    self.close()
188                    self.__connect_plain()
189                    self.protocol_messages.set_version(ProtocolVersions.bareos_12_4)
190                    auth = self.auth()
191                else:
192                    raise
193
194        return auth
195
196    def __connect_plain(self):
197        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
198        # initialize
199        try:
200            self.socket.connect((self.address, self.port))
201        except (socket.error, socket.gaierror) as e:
202            self._handleSocketError(e)
203            raise bareos.exceptions.ConnectionError(
204                "Failed to connect to host {0}, port {1}: {2}".format(
205                    self.address, self.port, str(e)
206                )
207            )
208
209        self.logger.debug("connected to {0}:{1}".format(self.address, self.port))
210
211        return True
212
213    def __connect_tls_psk(self):
214        """
215        Connect and establish a TLS-PSK connection on top of the connection.
216        """
217        self.__connect_plain()
218        # wrap socket with TLS-PSK
219        client_socket = self.socket
220        identity = self.get_tls_psk_identity()
221        if isinstance(self.password, Password):
222            password = self.password.md5()
223        else:
224            raise bareos.exceptions.ConnectionError(u"No password provided.")
225        self.logger.debug("identity = {0}, password = {1}".format(identity, password))
226        try:
227            self.socket = sslpsk.wrap_socket(
228                client_socket,
229                ssl_version=self.tls_version,
230                ciphers="ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
231                psk=(password, identity),
232                server_side=False,
233            )
234        except ssl.SSLError as e:
235            # raise ConnectionError(
236            #     "failed to connect to host {0}, port {1}: {2}".format(self.address, self.port, str(e)))
237            # Using a general raise keep more information about the type of error.
238            raise
239        return True
240
241    def get_tls_psk_identity(self):
242        """Bareos TLS-PSK excepts the identiy is a specific format."""
243        name = str(self.name)
244        if isinstance(self.name, bytes):
245            name = self.name.decode("utf-8")
246        result = u"{0}{1}{2}".format(self.identity_prefix, Constants.record_separator, name)
247        return bytes(bytearray(result, "utf-8"))
248
249
250    @staticmethod
251    def is_tls_psk_available():
252        """Checks if we have all required modules for TLS-PSK."""
253        return "sslpsk" in sys.modules
254
255    def get_protocol_version(self):
256        return self.protocol_messages.get_version()
257
258    def get_cipher(self):
259        if hasattr(self.socket, "cipher"):
260            return self.socket.cipher()
261        else:
262            return None
263
264    def auth(self):
265        """
266        Login to a Bareos Daemon.
267
268        @return: True, if the authentication succeeds.
269                 In earlier versions, authentication failures returned False.
270                 However, now an authentication failure raises an exception.
271        @rtype: bool
272
273        @raise bareos.exceptions.AuthenticationError: if authentication fails.
274        """
275
276        bashed_name = self.protocol_messages.hello(self.name, type=self.connection_type)
277        # send the bash to the director
278        self.send(bashed_name)
279
280        try:
281            (ssl, result_compatible, result) = self._cram_md5_respond(
282                password=self.password.md5(), tls_remote_need=0
283            )
284        except bareos.exceptions.SignalReceivedException as e:
285            self._handleSocketError(e)
286            raise bareos.exceptions.AuthenticationError(
287                "Received unexcepted signal: {0}".format(str(e))
288            )
289        if not result:
290            raise bareos.exceptions.AuthenticationError("failed (in response)")
291        if not self._cram_md5_challenge(
292            clientname=self.name,
293            password=self.password.md5(),
294            tls_local_need=0,
295            compatible=True,
296        ):
297            raise bareos.exceptions.AuthenticationError("failed (in challenge)")
298
299        self.finalize_authentication()
300
301        return self.auth_credentials_valid
302
303    def receive_and_evaluate_response_message(self):
304        regex_str = r"^(\d\d\d\d){0}(.*)$".format(
305            Constants.record_separator_compat_regex
306        )
307        regex = bytes(bytearray(regex_str, "utf8"))
308        incoming_message = self.recv_msg(regex)
309        match = re.search(regex, incoming_message, re.DOTALL)
310        code = int(match.group(1))
311        text = match.group(2)
312
313        return (code, text)
314
315    def _init_connection(self):
316        pass
317
318    def close(self):
319        """disconnect"""
320        if self.socket is not None:
321            self.socket.close()
322        self.socket = None
323
324    def reconnect(self):
325        result = False
326        if self.max_reconnects > 0:
327            try:
328                self.max_reconnects -= 1
329                if self.__connect() and self._init_connection():
330                    result = True
331            except (socket.error, bareos.exceptions.ConnectionLostError):
332                self.logger.warning("failed to reconnect")
333        return result
334
335    def call(self, command):
336        """
337        call a bareos-director user agent command
338        """
339        if isinstance(command, list):
340            command = " ".join(command)
341        return self._send_a_command_and_receive_result(command)
342
343    def _send_a_command_and_receive_result(self, command):
344        """
345        Send a command and receive the result.
346        If connection is lost, try to reconnect.
347        """
348        result = b""
349        try:
350            self.send(bytearray(command, "utf-8"))
351            result = self.recv_msg()
352        except (
353            bareos.exceptions.SocketEmptyHeader,
354            bareos.exceptions.ConnectionLostError,
355        ) as e:
356            self.logger.error(
357                "connection problem (%s): %s" % (type(e).__name__, str(e))
358            )
359            if self.reconnect():
360                return self._send_a_command_and_receive_result(command, count + 1)
361            else:
362                raise
363        return result
364
365    def send_command(self, command):
366        return self.call(command)
367
368    def send(self, msg=None):
369        """use socket to send request to director"""
370        self.__check_socket_connection()
371        msg_len = len(msg)  # plus the msglen info
372
373        try:
374            # convert to network flow
375            self.logger.debug("{0}".format(msg.rstrip()))
376            self.socket.sendall(struct.pack("!i", msg_len) + msg)
377        except socket.error as e:
378            self._handleSocketError(e)
379
380    def recv_bytes(self, length, timeout=10):
381        """
382        Receive a number of bytes.
383
384        @raise bareos.exceptions.ConnectionLostError:
385               is raised, if the socket connection gets lost.
386        @raise socket.timeout:
387               is raised, if a timeout occurs on the socket connection,
388               meaning no data received.
389        """
390        self.socket.settimeout(timeout)
391        msg = b""
392        # get the message
393        while length > 0:
394            self.logger.debug("expecting {0} bytes.".format(length))
395            submsg = self.socket.recv(length)
396            if len(submsg) == 0:
397                errormsg = u"Failed to retrieve data. Assuming the connection is lost."
398                self._handleSocketError(errormsg)
399                raise bareos.exceptions.ConnectionLostError(errormsg)
400            length -= len(submsg)
401            msg += submsg
402        return msg
403
404    def recv(self):
405        """
406        Receive a single message.
407        This is,
408        header (4 bytes): if
409            > 0: length of the following message
410            < 0: Bareos signal
411        msg: of the length descriped in the header.
412
413        @raise bareos.exceptions.SignalReceivedException:
414               is raised, if a Bareos signal is received.
415        """
416        self.__check_socket_connection()
417        # get the message header
418        header = self.__get_header()
419        if header <= 0:
420            self.logger.debug("header: " + str(header))
421            raise bareos.exceptions.SignalReceivedException(header)
422        # get the message
423        length = header
424        msg = self.recv_submsg(length)
425        return msg
426
427    def recv_msg(self, regex=b"^\d\d\d\d OK.*$"):
428        """
429        Receive a full Director message.
430
431        It retrieves Director messages (header + message text),
432        until
433          1. the message matches the specified regex or
434          2. the header indicates a signal.
435
436        @raise bareos.exceptions.SignalReceivedException:
437               is raised, if a Bareos signal is received.
438        """
439        self.__check_socket_connection()
440        try:
441            timeouts = 0
442            while True:
443                # get the message header
444                try:
445                    header = self.__get_header()
446                except (socket.timeout, ssl.SSLError) as exception:
447                    # When using a SSL connection,
448                    # a timeout is raised as
449                    # ssl.SSLError exception with message: 'The read operation timed out'.
450                    # ssl.SSLError is inherited from socket.error.
451                    # Because we can't be sure,
452                    # that it is really a timeout, we log it.
453                    if isinstance(exception, ssl.SSLError) and self.logger.isEnabledFor(
454                        logging.DEBUG
455                    ):
456                        # self.logger.exception('On SSL connections, timeout are raised as ssl.SSLError exceptions:')
457                        self.logger.debug("{0}".format(repr(exception)))
458                    self.logger.debug("timeout (%i) on receiving header" % (timeouts))
459                    timeouts += 1
460                else:
461                    if header <= 0:
462                        # header is a signal
463                        self.__set_status(header)
464                        if self.is_end_of_message(header):
465                            result = self.receive_buffer
466                            self.receive_buffer = b""
467                            return result
468                    else:
469                        # header is the length of the next message
470                        length = header
471                        submsg = self.recv_submsg(length)
472                        # check for regex in new submsg
473                        # and last line in old message,
474                        # which might have been incomplete without new submsg.
475                        lastlineindex = self.receive_buffer.rfind(b"\n") + 1
476                        self.receive_buffer += submsg
477                        match = re.search(
478                            regex, self.receive_buffer[lastlineindex:], re.DOTALL
479                        )
480                        # Bareos indicates end of command result by line starting with 4 digits
481                        if match:
482                            self.logger.debug(
483                                'msg "{0}" matches regex "{1}"'.format(
484                                    self.receive_buffer.strip(), regex
485                                )
486                            )
487                            result = self.receive_buffer[
488                                0 : lastlineindex + match.end()
489                            ]
490                            self.receive_buffer = self.receive_buffer[
491                                lastlineindex + match.end() + 1 :
492                            ]
493                            return result
494        except socket.error as e:
495            self._handleSocketError(e)
496
497    def recv_submsg(self, length):
498        # get the message
499        msg = self.recv_bytes(length)
500        if type(msg) is str:
501            msg = bytearray(msg.decode("utf-8"), "utf-8")
502        if type(msg) is bytes:
503            msg = bytearray(msg)
504        self.logger.debug(str(msg))
505        return msg
506
507    def interactive(self):
508        """
509        Enter the interactive mode.
510        Exit via typing "exit" or "quit".
511        """
512        command = ""
513        while command != "exit" and command != "quit" and self.is_connected():
514            try:
515                command = self._get_input()
516            except EOFError:
517                return False
518            try:
519                if command == "exit" or command == "quit":
520                    return True
521                resultmsg = self.call(command)
522                self._show_result(resultmsg)
523            except bareos.exceptions.JsonRpcErrorReceivedException as exp:
524                print(str(exp))
525                # print(str(exp.jsondata))
526
527        return True
528
529    def _get_input(self):
530        # Python2: raw_input, Python3: input
531        try:
532            myinput = raw_input
533        except NameError:
534            myinput = input
535        data = myinput(">>")
536        return data
537
538    def _show_result(self, msg):
539        # print(msg.decode('utf-8'))
540        sys.stdout.write(msg.decode("utf-8"))
541        # add a linefeed, if there isn't one already
542        if len(msg) >= 2:
543            if msg[-2] != ord(b"\n"):
544                sys.stdout.write("\n")
545
546    def __get_header(self, timeout=10):
547        header = self.recv_bytes(4, timeout)
548        return self.__get_header_data(header)
549
550    def __get_header_data(self, header):
551        # struct.unpack:
552        #   !: network (big/little endian conversion)
553        #   i: integer (4 bytes)
554        data = struct.unpack("!i", header)[0]
555        return data
556
557    def is_end_of_message(self, data):
558        return (
559            (not self.is_connected())
560            or data == Constants.BNET_EOD
561            or data == Constants.BNET_TERMINATE
562            or data == Constants.BNET_MAIN_PROMPT
563            or data == Constants.BNET_SUB_PROMPT
564        )
565
566    def is_connected(self):
567        return self.status != Constants.BNET_TERMINATE
568
569    def _cram_md5_challenge(
570        self, clientname, password, tls_local_need=0, compatible=True
571    ):
572        """
573        client launch the challenge,
574        client confirm the dir is the correct director
575        """
576
577        # get the timestamp
578        # here is the console
579        # to confirm the director so can do this on bconsole`way
580        rand = random.randint(1000000000, 9999999999)
581        # chal = "<%u.%u@%s>" %(rand, int(time.time()), self.dirname)
582        chal = "<%u.%u@%s>" % (rand, int(time.time()), clientname)
583        msg = bytearray("auth cram-md5 %s ssl=%d\n" % (chal, tls_local_need), "utf-8")
584        # send the confirmation
585        self.send(msg)
586        # get the response
587        msg = self.recv()
588        if msg[-1] == 0:
589            del msg[-1]
590        self.logger.debug("received: " + str(msg))
591
592        # hash with password
593        hmac_md5 = hmac.new(password, None, hashlib.md5)
594        hmac_md5.update(bytes(bytearray(chal, "utf-8")))
595        bbase64compatible = BareosBase64().string_to_base64(
596            bytearray(hmac_md5.digest()), True
597        )
598        bbase64notcompatible = BareosBase64().string_to_base64(
599            bytearray(hmac_md5.digest()), False
600        )
601        self.logger.debug("string_to_base64, compatible:     " + str(bbase64compatible))
602        self.logger.debug(
603            "string_to_base64, not compatible: " + str(bbase64notcompatible)
604        )
605
606        is_correct = (msg == bbase64compatible) or (msg == bbase64notcompatible)
607        # check against compatible base64 and Bareos specific base64
608        if is_correct:
609            self.send(ProtocolMessages.auth_ok())
610        else:
611            self.logger.error(
612                "expected result: %s or %s, but get %s"
613                % (bbase64compatible, bbase64notcompatible, msg)
614            )
615            self.send(ProtocolMessages.auth_failed())
616
617        # check the response is equal to base64
618        return is_correct
619
620    def _cram_md5_respond(self, password, tls_remote_need=0, compatible=True):
621        """
622        client connect to dir,
623        the dir confirm the password and the config is correct
624        """
625        # receive from the director
626        chal = ""
627        ssl = 0
628        result = False
629        msg = ""
630        try:
631            msg = self.recv()
632        except RuntimeError:
633            self.logger.error("RuntimeError exception in recv")
634            return (0, True, False)
635
636        # invalid username
637        if ProtocolMessages.is_not_authorized(msg):
638            self.logger.error("failed: " + str(msg))
639            return (0, True, False)
640
641        # check the receive message
642        self.logger.debug("(recv): " + str(msg).rstrip())
643
644        msg_list = msg.split(b" ")
645        chal = msg_list[2]
646        # get th timestamp and the tle info from director response
647        ssl = int(msg_list[3][4])
648        compatible = True
649        # hmac chal and the password
650        hmac_md5 = hmac.new((password), None, hashlib.md5)
651        hmac_md5.update(bytes(chal))
652
653        # base64 encoding
654        msg = BareosBase64().string_to_base64(bytearray(hmac_md5.digest()))
655
656        # send the base64 encoding to director
657        self.send(msg)
658        received = self.recv()
659        if ProtocolMessages.is_auth_ok(received):
660            result = True
661        else:
662            self.logger.error("failed: " + str(received))
663        return (ssl, compatible, result)
664
665    def __set_status(self, status):
666        self.status = status
667        status_text = Constants.get_description(status)
668        self.logger.debug(str(status_text) + " (" + str(status) + ")")
669
670    def has_data(self):
671        self.__check_socket_connection()
672        timeout = 0.1
673        readable, writable, exceptional = select([self.socket], [], [], timeout)
674        return readable
675
676    def get_to_prompt(self):
677        time.sleep(0.1)
678        if self.has_data():
679            msg = self.recv_msg()
680            self.logger.debug("received message: " + str(msg))
681        # TODO: check prompt
682        return True
683
684    def __check_socket_connection(self):
685        result = True
686        if self.socket is None:
687            result = False
688            if self.auth_credentials_valid:
689                # connection have worked before, but now it is gone
690                raise bareos.exceptions.ConnectionLostError(
691                    "currently no network connection"
692                )
693            else:
694                raise RuntimeError("should connect to director first before send data")
695        return result
696
697    def _handleSocketError(self, exception):
698        self.logger.warning("socket error: {0}".format(str(exception)))
699        self.close()
700