From 02e353f2fb699316c35dfb9e7002c82f6e474e45 Mon Sep 17 00:00:00 2001 From: Jarrod Johnson Date: Tue, 2 Jul 2013 17:03:24 -0400 Subject: [PATCH] Add SOL support SOL support is added in a manner that is actually functional Change-Id: I3f83e06b27a0d44038ac6e6afcd4f8af1c534946 --- ipmi/console.py | 252 +++++++++++++++++++++++++++++++++++++ ipmi/private/session.py | 267 +++++++++++++++++++++++++++++----------- solconnect.py | 46 +++++++ 3 files changed, 490 insertions(+), 75 deletions(-) create mode 100644 ipmi/console.py create mode 100644 solconnect.py diff --git a/ipmi/console.py b/ipmi/console.py new file mode 100644 index 00000000..6d776051 --- /dev/null +++ b/ipmi/console.py @@ -0,0 +1,252 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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 fcntl +import os +import struct + +from ipmi.private import session +from ipmi.private import constants + +class Console(object): + """IPMI SOL class. + + This object represents an SOL channel, multiplexing SOL data with + commands issued by ipmi.command. + + :param bmc: hostname or ip address of BMC + :param userid: username to use to connect + :param password: password to connect to the BMC + :param iohandler: Either a function to call with bytes, a filehandle to + use for input and output, or a tuple of (input, output) + handles + :param kg: optional parameter for BMCs configured to require it + """ + + #TODO(jbjohnso): still need an exit and a data callin function + def __init__(self, bmc, userid, password, + iohandler=None, + force=False, kg=None): + if type(iohandler) == tuple: #two file handles + self.console_in = iohandler[0] + self.console_out = iohandler[1] + elif type(iohandler) == file: # one full duplex file handle + self.console_out = iohandler + self.console_in = iohandler + elif type(iohander) == types.FunctionType: + self.console_out = None + self.out_handler = iohandler + else: + raise(Exception('No IO handler provided')) + if self.console_in is not None: + fcntl.fcntl(self.console_in.fileno(), fcntl.F_SETFL, os.O_NONBLOCK) + self.remseq = 0 + self.myseq = 0 + self.lastsize = 0 + self.sendbreak = 0 + self.ackedcount = 0 + self.ackedseq = 0 + self.retriedpayload = 0 + self.pendingoutput="" + self.awaitingack=False + self.force_session = force + self.ipmi_session = session.Session(bmc=bmc, + userid=userid, + password=password, + kg=kg, + onlogon=self._got_session + ) + + def _got_session(self, response): + """Private function to navigate SOL payload activation + """ + if 'error' in response: + self._print_data(response['error']) + return + #Send activate sol payload directive + #netfn= 6 (application) + #command = 0x48 (activate payload) + #data = (1, sol payload type + # 1, first instance + # 0b11000000, -encrypt, authenticate, + # disable serial/modem alerts, CTS fine + # 0, 0, 0 reserved + self.ipmi_session.raw_command(netfn=0x6, command=0x48, + data=(1, 1, 192, 0, 0, 0), + callback=self._payload_activated) + + def _payload_activated(self, response): + """Check status of activate payload request + """ + if 'error' in response: + self._print_data(response['error']) + #given that these are specific to the command, + #it's probably best if one can grep the error + #here instead of in constants + sol_activate_codes = { + 0x81: 'SOL is disabled', + 0x82: 'Maximum SOL session count reached', + 0x83: 'Cannot activate payload with encryption', + 0x84: 'Cannot activate payload without encryption', + } + if response['code']: + if response['code'] in constants.ipmi_completion_codes: + self._print_data( + constants.ipmi_completion_codes[response['code']]) + return + elif response['code'] == 0x80: + if self.force_session and not self.retriedpayload: + self.retriedpayload=1 + self.ipmi_session.raw_command(netfn=0x6, command=0x49, + data=(1, 1, 0, 0, 0, 0), + callback=self._got_session) + return + else: + self._print_data('SOL Session active for another client\n') + return + elif response['code'] in sol_activate_codes: + self._print_data(sol_activate_codes[response['code']]+'\n') + return + else: + self._print_data( + 'SOL encountered Unrecognized error code %d\n' % + response['code']) + return + #data[0:3] is reserved except for the test mode, which we don't use + data = response['data'] + self.maxoutcount = (data[5] << 8) + data[4] + #BMC tells us this is the maximum allowed size + #data[6:7] is the promise of how small packets are going to be, but we + #don't have any reason to worry about it + if (data[8] + (data[9] << 8)) != 623: + raise Exception("TODO(jbjohnso): support atypical SOL port number") + #ignore data[10:11] for now, the vlan detail, shouldn't matter to this + #code anyway... + self.ipmi_session.sol_handler=self._got_sol_payload + if self.console_in is not None: + self.ipmi_session.register_handle_callback(self.console_in, + self._got_cons_input) + + def _got_cons_input(self,handle): + """Callback for handle events detected by ipmi session + """ + self.pendingoutput += handle.read() + if not self.awaitingack: + self._sendpendingoutput() + + def _sendpendingoutput(self): + self.myseq += 1 + self.myseq &= 0xf + if self.myseq == 0: + self.myseq = 1 + payload = struct.pack("BBBB", + self.myseq, + self.ackedseq, + self.ackedseq, + self.sendbreak) + payload += self.pendingoutput + self.lasttextsize = len(self.pendingoutput) + self.pendingoutput = "" + self.awaitingack = True + payload = struct.unpack("%dB" % len(payload), payload) + self.lastpayload=payload + self.ipmi_session.send_payload(payload, payload_type=1) + + def _print_data(self,data): + """Convey received data back to caller in the format of their choice. + + Caller may elect to provide this class filehandle(s) or else give a + callback function that this class will use to convey data back to + caller. + """ + if self.console_out is not None: + self.console_out.write(data) + self.console_out.flush() + elif self.out_handler: #callback style.. + self.out_handler(data) + + def _got_sol_payload(self, payload): + """SOL payload callback + """ + #TODO(jbjohnso) test cases to throw some likely scenarios at functions + #for example, retry with new data, retry with no new data + #retry with unexpected sequence number + newseq = payload[0] & 0b1111 + ackseq = payload[1] & 0b1111 + ackcount = payload[2] + nacked = payload[3] & 0b1000000 + poweredoff = payload[3] & 0b100000 + deactivated = payload[3] & 0b10000 + #for now, ignore overrun. I assume partial NACK for this reason or for + #no reason would be treated the same, new payload with partial data + remdata = "" + remdatalen = 0 + if newseq != 0: #this packet at least has some data to send to us.. + if len(payload) > 4: + remdatalen = len(payload[4:]) #store remote data len before dupe + #retry logic, we must ack *this* many even if it is + #a retry packet with new partial data + remdata = struct.pack("%dB" % remdatalen, *payload[4:]) + if newseq == self.remseq: #it is a retry, but could have new data.. + if remdatalen > self.lastsize: + remdata = remdata[4 + self.lastsize:] + else: # no new data... + remdata = "" + else: #TODO(jbjohnso) what if remote sequence number is wrong?? + self.remseq = newseq + self.lastsize=remdatalen + self._print_data(remdata) + ackpayload = (0, self.remseq, remdatalen, 0) + #Why not put pending data into the ack? because it's rare + #and might be hard to decide what to do in the context of + #retry situation + self.ipmi_session.send_payload(ackpayload, + payload_type=1, retry=False) + if self.myseq != 0 and ackseq == self.myseq: #the bmc has something to + #say about our last xmit + self.awaitingack=False + if nacked > 0: #the BMC was in some way unhappy + if poweredoff: + self._print_data("Remote system is powered down\n") + if deactivated: + self._print_data("Remote IPMI console disconnected\n") + else: #retry all or part of packet, but in a new form + #also add pending output for efficiency and ease + newtext = self.lastpayload[4 + ackcount:] + self.pendingoutput = newtext + self.pendingoutput + self._sendpendingoutput() + elif self.awaitingack: #session has marked us as happy, but we are not + #this does mean that we will occasionally retry a packet + #sooner than retry suggests, but that's no big deal + self.ipmi_session.send_payload(payload=self.lastpayload, + payload_type=1) + + def main_loop(self): + """Process all events until no more sessions exist. + + If a caller is a simple little utility, provide a function to + eternally run the event loop. More complicated usage would be expected + to provide their own event loop behavior, though this could be used + within the greenthread implementation of caller's choice if desired. + """ + #wait_for_rsp promises to return a false value when no sessions are + #alive anymore + #TODO(jbjohnso): wait_for_rsp is not returning a true value from our own + #session + while (1): + session.Session.wait_for_rsp(timeout=600) diff --git a/ipmi/private/session.py b/ipmi/private/session.py index f0cb75ea..307d12a3 100644 --- a/ipmi/private/session.py +++ b/ipmi/private/session.py @@ -92,7 +92,29 @@ def get_ipmi_error(response, suffix=""): class Session: + """A class to manage common IPMI session logistics + + Almost all developers should not worry about this class and instead be + looking toward ipmi.Command and ipmi.Console. + + For those that do have to worry, the main interesting thing is that the + event loop can go one of two ways. Either a larger manager can query using + class methods + the soonest timeout deadline and the filehandles to poll and assume + responsibility for the polling, or it can register filehandles to be + watched. This is primarily of interest to Console class, which may have an + input filehandle to watch and can pass it to Session. + + :param bmc: hostname or ip address of the BMC + :param userid: username to use to connect + :param password: password to connect to the BMC + :param kg: optional parameter if BMC requires Kg be set + :param port: UDP port to communicate with, pretty much always 623 + :param onlogon: callback to receive notification of login completion + """ poller = select.poll() + ipmipoller = select.poll() + _external_handlers = {} bmc_handlers = {} waiting_sessions = {} peeraddr_to_nodes = {} @@ -126,6 +148,7 @@ class Session: curmax = cls.socket.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) cls.poller.register(cls.socket, select.POLLIN) + cls.ipmipoller.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 @@ -156,11 +179,12 @@ class Session: kg=None, onlogon=None, onlogonargs=None): + self.lastpayload = None self.bmc = bmc self.userid = userid self.password = password - self.noretry = False self.nowait = False + self.pendingpayloads = collections.deque([]) if kg is not None: self.kg = kg else: @@ -222,6 +246,8 @@ class Session: # this should gracefully be backwards compat, but some # 1.5 implementations checked reserved bits self.ipmi15only = 0 + self.sol_handler = None + # NOTE(jbjohnso): This is the callback handler for any SOL payload def _checksum(self, *data): # Two's complement over the data csum = sum(data) @@ -268,6 +294,7 @@ class Session: netfn, command, data=[], + retry=True, callback=None, callback_args=None): self.ipmicallbackargs = callback_args @@ -276,37 +303,57 @@ class Session: self.ipmicallback = self._generic_callback else: self.ipmicallback = callback - self._send_ipmi_net_payload(netfn, command, data) + self._send_ipmi_net_payload(netfn, command, data, retry=retry) + if retry: #in retry case, let the retry timers auto-indicate wait time + timeout=None + else: #if not retry, give it a second before surrending + timeout=1 + #In the synchronous case, wrap the event loop in this call + #The event loop is shared amongst python-ipmi session instances + #within a process. In this way, synchronous usage of the interface + #plays well with asynchronous use. In fact, this produces the behavior + #of only the constructor *really* needing a callback. From then on, + #synchronous usage of the class acts in a greenthread manner governed by + #order of data on the network if callback is None: while self.lastresponse is None: - Session.wait_for_rsp() + Session.wait_for_rsp(timeout=timeout) return self.lastresponse - def _send_ipmi_net_payload(self, netfn, command, data): + def _send_ipmi_net_payload(self, netfn, command, data, retry=True): ipmipayload = self._make_ipmi_payload(netfn, command, data) payload_type = constants.payload_types['ipmi'] + self.send_payload(payload=ipmipayload, payload_type=payload_type, + retry=retry) + + def send_payload(self, payload=None, payload_type=None, retry=True): + if self.lastpayload is not None: + #we already have a packet outgoing, make this + # a pending payload + # this way a simplistic BMC won't get confused + # and we also avoid having to do a more complicated + # retry mechanism where each payload is + # retried separately + self.pendingpayloads.append((payload,payload_type,retry)) + return + if payload_type is None: + payload_type = self.last_payload_type + if payload is None: + payload = self.lastpayload + message = [0x6, 0, 0xff, 0x07] # constant RMCP header for IPMI + if retry: + self.lastpayload = payload + self.last_payload_type = payload_type + message.append(self.authtype) + baretype = payload_type if self.integrityalgo: payload_type |= 0b01000000 if self.confalgo: payload_type |= 0b10000000 - self.send_payload(payload=ipmipayload, payload_type=payload_type) - - def send_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 constants.payload_types.values(): raise Exception("Unrecognized payload type %d" % baretype) message += struct.unpack("!4B", struct.pack("> 2 != self.expectednetfn or - payload[5] != self.expectedcmd): + if (payload[4] != self.seqlun or + payload[1] >> 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 @@ -833,13 +942,14 @@ class Session: (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.expectednetfn = 0x1ff # bigger than one byte means + #it can never match the one byte value by mistake self.expectedcmd = 0x1ff self.seqlun += 4 # prepare seqlun for next transmit self.seqlun &= 0xff # when overflowing, wrap around - del Session.waiting_sessions[self] - self.lastpayload = None # render retry mechanism utterly incapable of doing - # anything, though it shouldn't matter + Session.waiting_sessions.pop(self,None) + 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 @@ -851,6 +961,12 @@ class Session: del payload[0:2] response['data'] = payload self.timeout = initialtimeout + (0.5 * random.random()) + if len(self.pendingpayloads) > 0: + (nextpayload, nextpayloadtype, retry) = \ + self.pendingpayloads.popleft() + self.send_payload(payload=nextpayload, + payload_type=nextpayloadtype, + retry=nextretry) call_with_optional_args(self.ipmicallback, response, self.ipmicallbackargs) @@ -860,8 +976,6 @@ class Session: return self.nowait = True self.timeout += 1 - if self.noretry: - return if self.timeout > 5: response = {'error': 'timeout'} call_with_optional_args(self.ipmicallback, @@ -879,7 +993,7 @@ class Session: 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 + # If we can't be sure which RAKP was dropped or if 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() @@ -889,13 +1003,14 @@ class Session: # 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 + self.hasretried = 1 # remember so that we can track taboo + # combinations # of sequence number, netfn, and lun due to # ambiguity on the wire self.send_payload() self.nowait = False - def _xmit_packet(self): + def _xmit_packet(self, retry=True): if not self.nowait: # if we are retrying, we really need to get the # packet out and get our timeout updated Session.wait_for_rsp(timeout=0) # take a convenient opportunity @@ -903,10 +1018,12 @@ class Session: # applicable while Session.pending > Session.maxpending: Session.wait_for_rsp() - Session.waiting_sessions[self] = {} - Session.waiting_sessions[self]['ipmisession'] = self - Session.waiting_sessions[self]['timeout'] = self.timeout + time.time() - Session.pending += 1 + if retry: + Session.waiting_sessions[self] = {} + Session.waiting_sessions[self]['ipmisession'] = self + Session.waiting_sessions[self]['timeout'] = self.timeout + \ + time.time() + Session.pending += 1 if self.sockaddr: Session.socket.sendto(self.netpacket, self.sockaddr) else: # he have not yet picked a working sockaddr for this connection, @@ -916,12 +1033,12 @@ class Session: 0, socket.SOCK_DGRAM): sockaddr = res[4] - if (res[0] == socket.AF_INET): # convert the sockaddr to AF_INET6 + if (res[0] == socket.AF_INET): #convert the sockaddr AF_INET6 newhost = '::ffff:' + sockaddr[0] sockaddr = (newhost, sockaddr[1], 0, 0) Session.bmc_handlers[sockaddr] = self Session.socket.sendto(self.netpacket, sockaddr) - if self.sequencenumber: # seq number of zero will be left alone as it is + if self.sequencenumber: # seq number of zero will be left alone, it is # special, otherwise increment self.sequencenumber += 1 @@ -931,11 +1048,11 @@ class Session: 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=struct.unpack("4B", struct.pack("I", self.sessionid)), + data=struct.unpack("4B", + struct.pack("I", self.sessionid)), + retry=False, callback=callback, callback_args=callback_args) self.logged = 0 diff --git a/solconnect.py b/solconnect.py new file mode 100644 index 00000000..c0baf725 --- /dev/null +++ b/solconnect.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +@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. +""" +"""A simple little script to exemplify/test ipmi.console module +""" +import os +import sys +import fcntl + +import tty +import termios + +from ipmi import console + +tcattr = termios.tcgetattr(sys.stdin) +newtcattr = tcattr +#TODO: allow ctrl-c and crtl-z to go to remote console, add our own exit handler +newtcattr[-1][termios.VINTR] = 0 +newtcattr[-1][termios.VSUSP] = 0 +termios.tcsetattr(sys.stdin, termios.TCSADRAIN, newtcattr) + +tty.setcbreak(sys.stdin.fileno()) + +passwd = os.environ['IPMIPASSWORD'] + +try: + sol = console.Console(bmc=sys.argv[1], userid=sys.argv[2], password=passwd, + iohandler=(sys.stdin, sys.stdout), force=True) + sol.main_loop() +finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, tcattr)