# BAREOS - Backup Archiving REcovery Open Sourced## Copyright (C) 2015-2024 Bareos GmbH & Co. KG## This program is Free Software; you can redistribute it and/or# modify it under the terms of version three of the GNU Affero General Public# License as published by the Free Software Foundation and included# in the file LICENSE.## This program is distributed 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# along with this program; if not, write to the Free Software# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA# 02110-1301, USA."""Low Level socket methods to communicate with a Bareos Daemon."""# Authentication code is taken from# https://github.com/hanxiangduo/bacula-console-pythonimporthashlibimporthmacimportloggingimportrandomimportrefromselectimportselectimportsocketimportsslimportstructimportsysimporttimeimportwarningsfrombareos.bsock.constantsimportConstantsfrombareos.bsock.connectiontypeimportConnectionTypefrombareos.bsock.protocolmessageidsimportProtocolMessageIdsfrombareos.bsock.protocolmessagesimportProtocolMessagesfrombareos.bsock.protocolversionsimportProtocolVersionsfrombareos.util.bareosbase64importBareosBase64frombareos.util.passwordimportPasswordimportbareos.exceptions# The ssl module support TLS-PSK (Transport Layer Security - Pre-Shared-Key)# since Python >= 3.13.# For some older Python versions, the TLS-PSK functionality# can be added by the sslpsk module,# with implement TLS-PSK on top of the ssl module.# If it is also not available, we continue anyway,# but don't use TLS-PSK.ifnotgetattr(ssl,"HAS_PSK",False):
warnings.formatwarning=format_warning_shorttry:importsslpskexceptImportError:warnings.warn("Connection encryption via TLS-PSK is not available ""(not available in 'ssl' and extra module 'sslpsk' is not installed).")
[docs]classLowLevel(object):""" Low Level socket methods to communicate with a Bareos Daemon. This class should not be used by itself, only by inherited classed. """
[docs]@staticmethoddefargparser_get_bareos_parameter(args):"""Extract arguments. This method is usually used together with the method :py:func:`argparser_add_default_command_line_arguments`. Args: args (ArgParser.Namespace): Arguments retrieved by :py:func:`ArgumentParser.parse_args`. Returns: dict: The relevant parameter from args to initialize a connection. """result={}forkey,valueinvars(args).items():ifvalueisnotNone:ifkey.startswith("BAREOS_"):bareoskey=key.split("BAREOS_",1)[1]result[bareoskey]=valuereturnresult
def__init__(self):self.logger=logging.getLogger()self.logger.debug("init")self.status=Noneself.address=Noneself.timeout=30self.password=Noneself.pam_username=Noneself.pam_password=Noneself.port=Noneself.dirname=Noneself.socket=Noneself.auth_credentials_valid=Falseself.max_reconnects=0self.tls_psk_enable=Trueself.tls_psk_require=Falsetry:self.tls_version=ssl.PROTOCOL_TLSexceptAttributeError:self.tls_version=ssl.PROTOCOL_SSLv23self.connection_type=Noneself.requested_protocol_version=Noneself.protocol_messages=ProtocolMessages()# identity_prefix have to be set in each classself.identity_prefix="R_NONE"self.receive_buffer=b""def__del__(self):self.close()
[docs]defconnect(self,address,port,dirname,connection_type,name=None,password=None,timeout=None,):"""Establish a network connection and authenticate. Args: address (str): Address of the Bareos Director (hostname or IP). port (int): Port number of the Bareos Director. dirname (str, optional): Name of the Bareos Director. Deprecated, normally not required. connection_type (int): See :py:class:`bareos.bsock.connectiontype.ConnectionType`. name (str, optional): Credential name. password (str, bareos.util.Password): Credential password, in cleartext or as Password object. timeout (int, optional): Connection timeout in seconds. Default OS specific. Returns: bool: True, if the authentication succeeds. In earlier versions, authentication failures returned False. However, now an authentication failure raises an exception. Raises: bareos.exceptions.ConnectionError: If connection can be established. bareos.exceptions.PamAuthenticationError: If PAM authentication fails. bareos.exceptions.AuthenticationError: If Bareos authentication fails. """self.address=addressself.port=int(port)ifdirname:self.dirname=dirnameelse:self.dirname=addressiftimeout:self.timeout=timeoutself.connection_type=connection_typeself.name=nameifpasswordisNone:raisebareos.exceptions.ConnectionError("Parameter 'password' is required.")ifisinstance(password,Password):self.password=passwordelse:self.password=Password(password)returnself.__connect()
def__connect(self):connected=Falseconnected_plain=Falseauth=Falseifself.tls_psk_require:ifnotself.is_tls_psk_available():raisebareos.exceptions.ConnectionError("TLS-PSK is required, but not available.")ifnotself.tls_psk_enable:raisebareos.exceptions.ConnectionError("TLS-PSK is required, but not enabled.")ifself.tls_psk_enableandself.is_tls_psk_available():try:self.__connect_tls_psk()except(bareos.exceptions.ConnectionError,ssl.SSLError)ase:self._handleSocketError(e)ifself.tls_psk_require:raiseelse:self.logger.warning("Failed to connect via TLS-PSK. Trying plain connection.")else:connected=Trueself.logger.debug("Encryption: {0}".format(self.socket.cipher()))ifnotconnected:self.__connect_plain()connected=Trueconnected_plain=Trueself.logger.debug("Encryption: None")ifconnected:try:auth=self.auth()exceptbareos.exceptions.PamAuthenticationError:raiseexceptbareos.exceptions.AuthenticationError:if(self.connection_type==ConnectionType.DIRECTORandself.requested_protocol_versionisNoneandself.get_protocol_version()>ProtocolVersions.bareos_12_4):# reconnect and try old protocolself.logger.warning("Failed to connect using protocol version {0}. Trying protocol version {1}. ".format(self.get_protocol_version(),ProtocolVersions.bareos_12_4))self.close()self.__connect_plain()self.protocol_messages.set_version(ProtocolVersions.bareos_12_4)auth=self.auth()else:raisereturnauthdef__connect_plain(self):# initializetry:self.socket=socket.create_connection((self.address,self.port),timeout=self.timeout)except(socket.error,socket.gaierror)ase:self._handleSocketError(e)raisebareos.exceptions.ConnectionError("Failed to connect to host {0}, port {1}: {2}".format(self.address,self.port,str(e)))self.logger.debug("connected to {0}:{1}".format(self.address,self.port))returnTruedef__connect_tls_psk(self):""" Connect and establish a TLS-PSK connection on top of the connection. """self.__connect_plain()# wrap socket with TLS-PSKclient_socket=self.socketidentity=self.get_tls_psk_identity()ifisinstance(self.password,Password):password=self.password.md5()else:raisebareos.exceptions.ConnectionError("No password provided.")self.logger.debug("identity = {0}, password = {1}".format(identity,password))ciphers="ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH"ifgetattr(ssl,"HAS_PSK",False):context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)context.check_hostname=Falsecontext.set_ciphers(ciphers)context.set_psk_client_callback(lambdahint:(identity,password))self.socket=context.wrap_socket(client_socket,server_side=False)else:try:self.socket=sslpsk.wrap_socket(client_socket,ssl_version=self.tls_version,ciphers=ciphers,psk=(password,identity),server_side=False,)exceptssl.SSLErrorase:# raise ConnectionError(# "failed to connect to host {0}, port {1}: {2}".format(self.address, self.port, str(e)))# Using a general raise to keep more information about the type of error.raisereturnTrue
[docs]defget_tls_psk_identity(self):"""Bareos TLS-PSK excepts the identity is a specific format."""name=str(self.name)ifisinstance(self.name,bytes):name=self.name.decode("utf-8")result="{0}{1}{2}".format(self.identity_prefix,Constants.record_separator,name)returnbytes(bytearray(result,"utf-8"))
[docs]@staticmethoddefis_tls_psk_available():"""Checks if TLS-PSK is available."""returngetattr(ssl,"HAS_PSK",False)or("sslpsk"insys.modules)
[docs]defget_protocol_version(self):"""Get the Bareos Console protocol version that is used. Returns: int: Number that represents the Bareos Console protocol version (see :py:class:`bareos.bsock.protocolversions.ProtocolVersions`.) """returnself.protocol_messages.get_version()
[docs]defget_cipher(self):""" If a encrypted connection is used, returns information about the encryption. Else it returns None. Returns: tuple or None: Returns a three-value tuple containing the name of the cipher being used, the version of the SSL protocol that defines its use, and the number of secret bits being used. If the connection is unencrypted or has been established, returns None. """ifhasattr(self.socket,"cipher"):returnself.socket.cipher()else:returnNone
[docs]defauth(self):""" Login to a Bareos Daemon. Returns: bool: True, if the authentication succeeds. In earlier versions, authentication failures returned False. However, now an authentication failure raises an exception. Raises: bareos.exceptions.AuthenticationError: if authentication fails. """bashed_name=self.protocol_messages.hello(self.name,type=self.connection_type)# send the bash to the directorself.send(bashed_name)try:(ssl,result_compatible,result)=self._cram_md5_respond(password=self.password.md5(),tls_remote_need=0)exceptbareos.exceptions.SignalReceivedExceptionase:self._handleSocketError(e)raisebareos.exceptions.AuthenticationError("Received unexcepted signal: {0}".format(str(e)))ifnotresult:raisebareos.exceptions.AuthenticationError("failed (in response)")ifnotself._cram_md5_challenge(clientname=self.name,password=self.password.md5(),tls_local_need=0,compatible=True,):raisebareos.exceptions.AuthenticationError("failed (in challenge)")self._finalize_authentication()returnself.auth_credentials_valid
[docs]defreceive_and_evaluate_response_message(self):"""Retrieve a message and evaluate it. Only used during in the authentication phase. Returns: 2-tuple: (code, text). """regex_str=r"^(\d\d\d\d){0}(.*)$".format(Constants.record_separator_compat_regex)regex=bytes(bytearray(regex_str,"utf8"))incoming_message=self.recv_msg(regex)match=re.search(regex,incoming_message,re.DOTALL)code=int(match.group(1))text=match.group(2)return(code,text)
def_init_connection(self):pass
[docs]defclose(self):"""Close the connection."""ifself.socketisnotNone:self.socket.close()self.socket=None
[docs]defreconnect(self):""" Tries to reconnect. Returns: bool: True, if the connection could be reestablished. """result=Falseifself.max_reconnects>0:try:self.max_reconnects-=1ifself.__connect()andself._init_connection():result=Trueexcept(socket.error,bareos.exceptions.ConnectionLostError):self.logger.warning("failed to reconnect")returnresult
[docs]defcall(self,command):"""Call a Bareos command. Args: command (str or list): Command to execute. Best provided as a list. Returns: bytes: Result received from the Daemon. """ifisinstance(command,list):command=" ".join(command)returnself._send_a_command_and_receive_result(command)
def_send_a_command_and_receive_result(self,command):"""Send a command and receive the result. If connection is lost, try to reconnect. Args: command (str or list): Command to execute. Best provided as a list. Returns: bytes: Result received from the Daemon. Raises: bareos.exceptions.SocketEmptyHeader: if an empty header is received. bareos.exceptions.ConnectionLostError: if the connection is lost- """result=b""try:self.send(bytearray(command,"utf-8"))result=self.recv_msg()except(bareos.exceptions.SocketEmptyHeader,bareos.exceptions.ConnectionLostError,)ase:self.logger.error("connection problem (%s): %s"%(type(e).__name__,str(e)))ifself.reconnect():returnself._send_a_command_and_receive_result(command,count+1)else:raisereturnresult
[docs]defsend_command(self,command):"""Alias for :py:func:`call`. :deprecated: 15.2.0 """returnself.call(command)
[docs]defsend(self,msg=None):"""Send message to the Daemon. Args: msg (bytearray): Message to send. """self.__check_socket_connection()msg_len=len(msg)# plus the msglen infotry:# convert to network flowself.logger.debug("{0}".format(msg.rstrip()))self.socket.sendall(struct.pack("!i",msg_len)+msg)exceptsocket.errorase:self._handleSocketError(e)
[docs]defrecv_bytes(self,length,timeout=10):"""Receive a number of bytes. Args: length (int): Number of bytes to receive. timeout (float): Timeout in seconds. Raises: bareos.exceptions.ConnectionLostError: If the socket connection gets lost. socket.timeout: If a timeout occurs on the socket connection, meaning no data received. """self.socket.settimeout(timeout)msg=b""# get the messagewhilelength>0:self.logger.debug("expecting {0} bytes.".format(length))submsg=self.socket.recv(length)iflen(submsg)==0:errormsg="Failed to retrieve data. Assuming the connection is lost."self._handleSocketError(errormsg)raisebareos.exceptions.ConnectionLostError(errormsg)length-=len(submsg)msg+=submsgreturnmsg
[docs]defrecv(self):"""Receive a single message. This is, header (4 bytes): if * > 0: length of the following message * < 0: Bareos signal Returns: bytearray: Message retrieved via the connection. Raises: bareos.exceptions.SignalReceivedException: If a Bareos signal is received. """self.__check_socket_connection()# get the message headerheader=self.__get_header()ifheader<=0:self.logger.debug("header: "+str(header))raisebareos.exceptions.SignalReceivedException(header)# get the messagelength=headermsg=self.recv_submsg(length)returnmsg
[docs]defrecv_msg(self,regex=b"^\\d\\d\\d\\d OK.*$"):"""Receive a full message. It retrieves messages (header + message text), until 1. the message contains the specified regex or 2. the header indicates a signal. Args: regex (bytes): Descripes the expected end of the message. Returns: bytearray: Message retrieved via the connection. Raises: bareos.exceptions.SignalReceivedException: If a Bareos signal is received. """self.__check_socket_connection()try:timeouts=0whileTrue:# get the message headertry:header=self.__get_header()except(socket.timeout,ssl.SSLError)asexception:# When using a SSL connection,# a timeout is raised as# ssl.SSLError exception with message: 'The read operation timed out'.# ssl.SSLError is inherited from socket.error.# Because we can't be sure,# that it is really a timeout, we log it.ifisinstance(exception,ssl.SSLError)andself.logger.isEnabledFor(logging.DEBUG):# self.logger.exception('On SSL connections, timeout are raised as ssl.SSLError exceptions:')self.logger.debug("{0}".format(repr(exception)))self.logger.debug("timeout (%i) on receiving header"%(timeouts))timeouts+=1else:ifheader<=0:# header is a signalself.__set_status(header)ifself.is_end_of_message(header):result=self.receive_bufferself.receive_buffer=b""returnresultelse:# header is the length of the next messagelength=headersubmsg=self.recv_submsg(length)# check for regex in new submsg# and last line in old message,# which might have been incomplete without new submsg.lastlineindex=self.receive_buffer.rfind(b"\n")+1self.receive_buffer+=submsgmatch=re.search(regex,self.receive_buffer[lastlineindex:],re.DOTALL)# Bareos indicates end of command result by line starting with 4 digitsifmatch:self.logger.debug('msg "{0}" matches regex "{1}"'.format(self.receive_buffer.strip(),regex))result=self.receive_buffer[0:lastlineindex+match.end()]self.receive_buffer=self.receive_buffer[lastlineindex+match.end()+1:]returnresultexceptsocket.errorase:self._handleSocketError(e)
[docs]defrecv_submsg(self,length):"""Retrieve a message of the specific length. Args: length (int): Number of bytes to retrieve. Returns: bytearray: Retrieved message. """msg=self.recv_bytes(length)iftype(msg)isstr:msg=bytearray(msg.decode("utf-8","replace"),"utf-8")iftype(msg)isbytes:msg=bytearray(msg)self.logger.debug(str(msg))returnmsg
[docs]definteractive(self):"""Enter the interactive mode. Exit via typing "exit" or "quit". Returns: bool: True, if exited by user command. """command=""whilecommand!="exit"andcommand!="quit"andself.is_connected():try:command=self._get_input()exceptEOFError:returnFalsetry:ifcommand=="exit"orcommand=="quit":returnTrueresultmsg=self.call(command)self._show_result(resultmsg)exceptbareos.exceptions.JsonRpcErrorReceivedExceptionasexp:print(str(exp))# print(str(exp.jsondata))returnTrue
def_get_input(self):# Wrapper to retrieve user keyboard input.# Python2: raw_input, Python3: inputtry:myinput=raw_inputexceptNameError:myinput=inputdata=myinput(">>")returndatadef_show_result(self,msg):# print(msg.decode('utf-8'))sys.stdout.write(msg.decode("utf-8","replace"))# add a linefeed, if there isn't one alreadyiflen(msg)>=2:ifmsg[-2]!=ord(b"\n"):sys.stdout.write("\n")def__get_header(self,timeout=10):header=self.recv_bytes(4,timeout)returnself.__get_header_data(header)def__get_header_data(self,header):# struct.unpack:# !: network (big/little endian conversion)# i: integer (4 bytes)data=struct.unpack("!i",header)[0]returndata
[docs]defis_end_of_message(self,data):"""Checks if a Bareos signal indicates the end of a message. Args: data (int): Negative integer. Returns: bool: True, if regular end of message is reached. """return((notself.is_connected())ordata==Constants.BNET_EODordata==Constants.BNET_TERMINATEordata==Constants.BNET_MAIN_PROMPTordata==Constants.BNET_SUB_PROMPT)
[docs]defis_connected(self):"""Verifes that last status still indicates connected. Returns: bool: True, if still connected. """returnself.status!=Constants.BNET_TERMINATE
def_cram_md5_challenge(self,clientname,password,tls_local_need=0,compatible=True):""" client launch the challenge, client confirm the dir is the correct director """# get the timestamp# here is the console# to confirm the director so can do this on bconsole`wayrand=random.randint(1000000000,9999999999)# chal = "<%u.%u@%s>" %(rand, int(time.time()), self.dirname)chal="<%u.%u@%s>"%(rand,int(time.time()),clientname)msg=bytearray("auth cram-md5 %s ssl=%d\n"%(chal,tls_local_need),"utf-8")# send the confirmationself.send(msg)# get the responsemsg=self.recv()ifmsg[-1]==0:delmsg[-1]self.logger.debug("received: "+str(msg))# hash with passwordhmac_md5=hmac.new(password,None,hashlib.md5)hmac_md5.update(bytes(bytearray(chal,"utf-8")))bbase64compatible=BareosBase64().string_to_base64(bytearray(hmac_md5.digest()),True)bbase64notcompatible=BareosBase64().string_to_base64(bytearray(hmac_md5.digest()),False)self.logger.debug("string_to_base64, compatible: "+str(bbase64compatible))self.logger.debug("string_to_base64, not compatible: "+str(bbase64notcompatible))is_correct=(msg==bbase64compatible)or(msg==bbase64notcompatible)# check against compatible base64 and Bareos specific base64ifis_correct:self.send(ProtocolMessages.auth_ok())else:self.logger.error("expected result: %s or %s, but get %s"%(bbase64compatible,bbase64notcompatible,msg))self.send(ProtocolMessages.auth_failed())# check the response is equal to base64returnis_correctdef_cram_md5_respond(self,password,tls_remote_need=0,compatible=True):""" client connect to dir, the dir confirm the password and the config is correct """# receive from the directorchal=""ssl=0result=Falsemsg=""try:msg=self.recv()exceptRuntimeError:self.logger.error("RuntimeError exception in recv")return(0,True,False)# invalid usernameifProtocolMessages.is_not_authorized(msg):self.logger.error("failed: "+str(msg))return(0,True,False)# check the receive messageself.logger.debug("(recv): "+str(msg).rstrip())msg_list=msg.split(b" ")chal=msg_list[2]# get th timestamp and the tle info from director responsessl=int(msg_list[3][4])compatible=True# hmac chal and the passwordhmac_md5=hmac.new((password),None,hashlib.md5)hmac_md5.update(bytes(chal))# base64 encodingmsg=BareosBase64().string_to_base64(bytearray(hmac_md5.digest()))# send the base64 encoding to directorself.send(msg)received=self.recv()ifProtocolMessages.is_auth_ok(received):result=Trueelse:self.logger.error("failed: "+str(received))return(ssl,compatible,result)def__set_status(self,status):self.status=statusstatus_text=Constants.get_description(status)self.logger.debug(str(status_text)+" ("+str(status)+")")
[docs]defhas_data(self):"""Is readable data available? Returns: bool: True: if readable data is available. """self.__check_socket_connection()timeout=0.1readable,writable,exceptional=select([self.socket],[],[],timeout)returnreadable
def_get_to_prompt(self):time.sleep(0.1)ifself.has_data():msg=self.recv_msg()self.logger.debug("received message: "+str(msg))# TODO: check promptreturnTruedef__check_socket_connection(self):result=Trueifself.socketisNone:result=Falseifself.auth_credentials_valid:# connection have worked before, but now it is goneraisebareos.exceptions.ConnectionLostError("currently no network connection")else:raiseRuntimeError("should connect to director first before send data")returnresultdef_handleSocketError(self,exception):self.logger.warning("socket error: {0}".format(str(exception)))self.close()