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