#!/usr/bin/env python # This represents the low layer message framing portion of IPMI import os import select from Crypto.Hash import HMAC, SHA from Crypto.Cipher import AES import socket import atexit from collections import deque from time import time from hashlib import md5 from struct import pack, unpack from ipmi_constants import payload_types, ipmi_completion_codes, command_completion_codes, payload_types, rmcp_codes from random import random 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['cmd'] if code == 0: return False if command in command_completion_codes and code in command_completion_codes[command]: return command_completion_codes[command][code]+suffix elif code in ipmi_completion_codes: return ipmi_completion_codes[code]+suffix else: return "Unknown code "+code+" encountered" class IPMISession: 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: 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 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(IPMISession,'socket'): self._createsocket() self.login() if not self.async: while not self.logged: IPMISession.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: 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: IPMISession.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: OEM Payloads") elif (baretype == 1): raise Exception("TODO: 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: SHA256 which is now allowed integdata = message[4:] neededpad=(len(integdata)-2)%4 if neededpad: needpad = 4-neededpad message += [0xff]*neededpad message.append(neededpad) message.append(7) #reserved, 7 is the required value for the specification followed 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 and payload[5] == self.expectedcmd): return -1 #this payload is not a match for our outstanding ipmi 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 IPMISession.waiting_sessions[self] self.lastpayload=None #render retry mechanism utterly incapable of doing anything, though it shouldn't matter self.last_payload_type=None del payload[0:5] # remove header of rsaddr/netfn/lun/checksum/rq/seq/lun del payload[-1] # remove the trailing checksum response={} response['cmd']=payload[0] response['code']=payload[1] del payload[0:2] response['data']=payload self.timeout=initialtimeout+(0.5*random()) if self.ipmicallbackargs is not None: args=(response,self.ipmicallbackargs) else: args=(response,) self.ipmicallback(*args) def _timedout(self): raise Exception("TODO: proper retries") def _xmit_packet(self,waitforpending=True): if waitforpending: IPMISession.wait_for_rsp(timeout=0) #take a convenient opportunity to drain the socket queue if applicable while IPMISession.pending > IPMISession.maxpending: IPMISession.wait_for_rsp() IPMISession.waiting_sessions[self]={} IPMISession.waiting_sessions[self]['ipmisession']=self IPMISession.waiting_sessions[self]['timeout']=self.timeout+time() IPMISession.pending+=1 if self.sockaddr: IPMISession.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) IPMISession.bmc_handlers[sockaddr]=self IPMISession.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=1 #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 = IPMISession(bmc=sys.argv[1],userid=sys.argv[2],password=os.environ['IPMIPASS']) print ipmis.raw_command(command=2,data=[1],netfn=0)