""" @author: Jarrod Johnson Copyright 2013 IBM Corporation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ # This represents the low layer message framing portion of IPMI import atexit from collections import deque from hashlib import md5 import os from random import random import select import socket from struct import pack, unpack from time import time from Crypto.Cipher import AES from Crypto.Hash import HMAC, SHA from ipmi_constants import payload_types, ipmi_completion_codes, command_completion_codes, payload_types, rmcp_codes initialtimeout = 0.5 #minimum timeout for first packet to retry in any given #session. This will be randomized to stagger out retries #in case of congestion def _aespad(data): # ipmi demands a certain pad scheme, per table 13-20 AES-CBC # encrypted payload fields newdata=list(data) currlen=len(data)+1 #need to count the pad length field as well neededpad=currlen%16 if neededpad: #if it happens to be zero, hurray, but otherwise invert the #sense of the padding neededpad = 16-neededpad padval=1 while padval <= neededpad: newdata.append(padval) padval+=1 newdata.append(neededpad) return newdata ''' In order to simplify things, in a number of places there is a callback facility and optional arguments to pass in. An OO oriented caller may find the additional argument needless. Allow them to ignore it by skipping the argument if None ''' def call_with_optional_args(callback,*args): newargs=[] for arg in args: if arg is not None: newargs.append(arg) callback(*newargs) def get_ipmi_error(response,suffix=""): if 'error' in response: return response['error']+suffix code = response['code'] command = response['command'] netfn = response['netfn'] if code == 0: return False if ((netfn,command) in command_completion_codes and code in command_completion_codes[(netfn,command)]): return command_completion_codes[(netfn,command)][code]+suffix elif code in ipmi_completion_codes: return ipmi_completion_codes[code]+suffix else: return "Unknown code "+code+" encountered" class ipmi_session: poller=select.poll() bmc_handlers={} waiting_sessions={} peeraddr_to_nodes={} #Upon exit of python, make sure we play nice with BMCs by assuring closed #sessions for all that we tracked @classmethod def _cleanup(cls): for session in cls.bmc_handlers.itervalues(): session.logout() @classmethod def _createsocket(cls): atexit.register(cls._cleanup) cls.socket = socket.socket(socket.AF_INET6,socket.SOCK_DGRAM) #INET6 #can do IPv4 if you are nice to it try: #we will try to fixup our receive buffer size if we are smaller #than allowed. maxmf = open("/proc/sys/net/core/rmem_max") rmemmax = int(maxmf.read()) rmemmax = rmemmax/2 curmax=cls.socket.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF) curmax = curmax/2 if (rmemmax > curmax): cls.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rmemmax) except: pass curmax=cls.socket.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF) cls.poller.register(cls.socket,select.POLLIN) curmax = curmax/2 #we throttle such that we never have no more outstanding packets than #our receive buffer should be able to handle cls.pending=0 cls.maxpending=curmax/1000 #pessimistically assume 1 kilobyte messages, #way larger than almost all ipmi datagrams #for faster performance, sysadmins may want to examine and tune #/proc/sys/net/core/rmem_max up. This allows the module to request #more, but does not increase buffers for applications that do less #creative things #TODO(jbjohnso): perhaps spread sessions across a socket pool when #rmem_max is small, still get ~65/socket, but avoid long queues that #might happen with low rmem_max and putting thousands of nodes in line ''' This function handles the synchronous caller case in liue of a client provided callback ''' def _sync_login(self,response): if 'error' in response: raise Exception(response['error']) def __init__(self, bmc, userid, password, port=623, kg=None, onlogon=None, onlogonargs=None): self.bmc=bmc self.userid=userid self.password=password self.noretry=False self.nowait=False if kg is not None: self.kg=kg else: self.kg=password self.port=port self.onlogonargs=onlogonargs if (onlogon is None): self.async=False self.onlogon=self._sync_login else: self.async=True self.onlogon=onlogon if not hasattr(ipmi_session,'socket'): self._createsocket() self.login() if not self.async: while not self.logged: ipmi_session.wait_for_rsp() def _initsession(self): self.localsid=2017673555 #this number can be whatever we want. I picked #'xCAT' minus 1 so that a hexdump of packet # would show xCAT self.privlevel=4 #for the moment, assume admin access #TODO(jbjohnso): make flexible self.confalgo=0 self.aeskey=None self.integrityalgo=0 self.k1=None self.rmcptag=1 self.ipmicallback=None self.ipmicallbackargs=None self.sessioncontext=None self.sequencenumber=0 self.sessionid=0 self.authtype=0 self.ipmiversion=1.5 self.timeout=initialtimeout+(0.5*random()) self.seqlun=0 self.rqaddr=0x81 #per IPMI table 5-4, software ids in the ipmi spec may #be 0x81 through 0x8d. We'll stick with 0x81 for now, #do not forsee a reason to adjust self.logged=0 self.sockaddr=None #when we confirm a working sockaddr, put it here to #skip getaddrinfo self.tabooseq={} #this tracks netfn,command,seqlun combinations that #were retried so that we don't loop around and reuse #the same request data and cause potential ambiguity #in return self.ipmi15only=0 #default to supporting ipmi 2.0. Strictly by spec, #this should gracefully be backwards compat, but some #1.5 implementations checked reserved bits def _checksum(self,*data): #Two's complement over the data csum=sum(data) csum=csum^0xff csum+=1 csum &= 0xff return csum ''' This function generates the core ipmi payload that would be applicable for any channel (including KCS) ''' def _make_ipmi_payload(self,netfn,command,data=()): self.expectedcmd=command self.expectednetfn=netfn+1 #in ipmi, the response netfn is always one #higher than the request payload, we assume #we are always the requestor for now seqincrement=7 #IPMI spec forbids gaps bigger then 7 in seq number. #Risk the taboo rather than violate the rules while (netfn,command,self.seqlun) in self.tabooseq and self.tabooseq[(netfn,command,self.seqlun)] and seqincrement: self.tabooseq[(self.expectednetfn,command,self.seqlun)]-=1 #Allow taboo to eventually expire after a few rounds self.seqlun += 4 #the last two bits are lun, so add 4 to add 1 self.seqlun &= 0xff #we only have one byte, wrap when exceeded seqincrement-=1 header=[0x20,netfn<<2] #figure 13-4, first two bytes are rsaddr and #netfn, rsaddr is always 0x20 since we are #addressing BMC reqbody=[self.rqaddr,self.seqlun,command]+list(data) headsum=self._checksum(*header) bodysum=self._checksum(*reqbody) payload=header+[headsum]+reqbody+[bodysum] return payload def _generic_callback(self,response): errorstr = get_ipmi_error(response) if errorstr: response['error']=errorstr self.lastresponse=response def raw_command(self, netfn, command, data=[], callback=None, callback_args=None): self.ipmicallbackargs=callback_args if callback is None: self.lastresponse=None self.ipmicallback=self._generic_callback else: self.ipmicallback=callback self._send_ipmi_net_payload(netfn,command,data) if callback is None: while self.lastresponse is None: ipmi_session.wait_for_rsp() return self.lastresponse def _send_ipmi_net_payload(self,netfn,command,data): ipmipayload=self._make_ipmi_payload(netfn,command,data) payload_type = payload_types['ipmi'] if self.integrityalgo: payload_type |= 0b01000000 if self.confalgo: payload_type |= 0b10000000 self._pack_payload(payload=ipmipayload,payload_type=payload_type) def _pack_payload(self,payload=None,payload_type=None): if payload is None: payload=self.lastpayload if payload_type is None: payload_type=self.last_payload_type message = [0x6,0,0xff,0x07] #constant RMCP header for IPMI baretype = payload_type & 0b00111111 self.lastpayload=payload self.last_payload_type=payload_type message.append(self.authtype) if (self.ipmiversion == 2.0): message.append(payload_type) if (baretype == 2): raise Exception("TODO(jbjohnso): OEM Payloads") elif (baretype == 1): raise Exception("TODO(jbjohnso): SOL Payload") elif baretype not in payload_types.values(): raise Exception("Unrecognized payload type %d"%baretype) message += unpack("!4B",pack(">8); iv=os.urandom(16) message += list(unpack("16B",iv)) payloadtocrypt=_aespad(payload) crypter = AES.new(self.aeskey,AES.MODE_CBC,iv) crypted = crypter.encrypt(pack("%dB"%len(payloadtocrypt), *payloadtocrypt)) crypted = list(unpack("%dB"%len(crypted),crypted)) message += crypted else: #no confidetiality algorithm message.append(psize&0xff) message.append(psize>>8); message += list(payload) if self.integrityalgo: #see table 13-8, #RMCP+ packet format #TODO(jbjohnso): SHA256 which is now allowed neededpad=(len(message)-2)%4 if neededpad: neededpad = 4-neededpad message += [0xff]*neededpad message.append(neededpad) message.append(7) #reserved, 7 is the required value for the #specification followed integdata = message[4:] authcode = HMAC.new(self.k1, pack("%dB"%len(integdata), *integdata), SHA).digest()[:12] #SHA1-96 #per RFC2404 truncates to 96 bits message += unpack("12B",authcode) self.netpacket = pack("!%dB"%len(message),*message) self._xmit_packet() def _ipmi15authcode(self,payload,checkremotecode=False): if self.authtype == 0: #Only for things prior to auth in ipmi 1.5, not #like 2.0 cipher suite 0 return () password = self.password padneeded = 16 - len(password) if padneeded < 0: raise Exception("Password is too long for ipmi 1.5") password += '\x00'*padneeded passdata = unpack("16B",password) if checkremotecode: seqbytes = unpack("!4B",pack(" 16: raise Exception("Username too long for IPMI, must not exceed 16") padneeded=16-len(self.userid) userid=self.userid+('\x00'*padneeded) reqdata += unpack("!16B",userid) self.ipmicallback=self._got_session_challenge self._send_ipmi_net_payload(netfn=0x6,command=0x39,data=reqdata) def _open_rmcpplus_request(self): self.authtype=6 self.localsid+=1 #have unique local session ids to ignore aborted login attempts from the past self.rmcptag+=1 data = [ self.rmcptag, 0, #request as much privilege as the channel will give us 0,0, #reserved ] data += list(unpack("4B",pack(">2 != self.expectednetfn or payload[5] != self.expectedcmd): return -1 #this payload is not a match for our outstanding packet if hasattr(self,'hasretried') and self.hasretried: self.hasretried=0 self.tabooseq[(self.expectednetfn,self.expectedcmd,self.seqlun)]=16 #try to skip it for at most 16 cycles of overflow #We want to now remember that we do not have an expected packet self.expectednetfn=0x1ff #bigger than one byte means it can never match self.expectedcmd=0x1ff self.seqlun += 4 #prepare seqlun for next transmit self.seqlun &= 0xff #when overflowing, wrap around del ipmi_session.waiting_sessions[self] self.lastpayload=None #render retry mechanism utterly incapable of doing #anything, though it shouldn't matter self.last_payload_type=None response={} response['netfn'] = payload[1]>>2 del payload[0:5] # remove header of rsaddr/netfn/lun/checksum/rq/seq/lun del payload[-1] # remove the trailing checksum response['command']=payload[0] response['code']=payload[1] del payload[0:2] response['data']=payload self.timeout=initialtimeout+(0.5*random()) call_with_optional_args(self.ipmicallback, response, self.ipmicallbackargs) def _timedout(self): if not self.lastpayload: return self.nowait=True self.timeout += 1 if self.noretry: return if self.timeout > 5: response={'error': 'timeout'} call_with_optional_args(self.ipmicallback, response, self.ipmicallbackargs) self.nowait=False return elif self.sessioncontext == 'FAILED': self.nowait=False return if self.sessioncontext == 'OPENSESSION': #In this case, we want to craft a new session request to have #unambiguous session id regardless of how packet was dropped or #delayed in this case, it's safe to just redo the request self._open_rmcpplus_request() elif self.sessioncontext == 'EXPECTINGRAKP2' or self.sessioncontext == 'EXPECTINGRAKP4': #If we can't be sure which RAKP was dropped or that RAKP3/4 was just #delayed, the most reliable thing to do is rewind and start over #bmcs do not take kindly to receiving RAKP1 or RAKP3 twice self._relog() else: #in IPMI case, the only recourse is to act as if the packet is #idempotent. SOL has more sophisticated retry handling #the biggest risks are reset sp, which is often fruitless to retry #and chassis reset, which sometimes will shoot itself #systematically in the head in a shared port case making replies #impossible self.hasretried=1 #remember so that we can track taboo combinations #of sequence number, netfn, and lun due to #ambiguity on the wire self._pack_payload() self.nowait=False def _xmit_packet(self): if not self.nowait: #if we are retrying, we really need to get the #packet out and get our timeout updated ipmi_session.wait_for_rsp(timeout=0) #take a convenient opportunity #to drain the socket queue if #applicable while ipmi_session.pending > ipmi_session.maxpending: ipmi_session.wait_for_rsp() ipmi_session.waiting_sessions[self]={} ipmi_session.waiting_sessions[self]['ipmisession']=self ipmi_session.waiting_sessions[self]['timeout']=self.timeout+time() ipmi_session.pending+=1 if self.sockaddr: ipmi_session.socket.sendto(self.netpacket,self.sockaddr) else: #he have not yet picked a working sockaddr for this connection, #try all the candidates that getaddrinfo provides for res in socket.getaddrinfo(self.bmc, self.port, 0, socket.SOCK_DGRAM): sockaddr = res[4] if (res[0] == socket.AF_INET): #convert the sockaddr to AF_INET6 newhost='::ffff:'+sockaddr[0] sockaddr = (newhost,sockaddr[1],0,0) ipmi_session.bmc_handlers[sockaddr]=self ipmi_session.socket.sendto(self.netpacket,sockaddr) if self.sequencenumber: #seq number of zero will be left alone as it is #special, otherwise increment self.sequencenumber += 1 def logout(self,callback=None,callback_args=None): if not self.logged: if callback is None: return {'success': True } callback({'success': True }) return self.noretry=True #risk open sessions if logout request gets dropped, #logout is not idempotent so this is the better bet self.raw_command(command=0x3c, netfn=6, data=unpack("4B",pack("I",self.sessionid)), callback=callback, callback_args=callback_args) self.logged=0 if callback is None: return {'success': True } callback({'success': True }) if __name__ == "__main__": import sys ipmis = ipmi_session(bmc=sys.argv[1],userid=sys.argv[2],password=os.environ['IPMIPASS']) print ipmis.raw_command(command=2,data=[1],netfn=0) print get_ipmi_error({'command':8,'code':128,'netfn':1})