blob: e52bfbee13c72d13f0d31371a7987f9f9f15f36a [file] [log] [blame]
# Copyright 2017-present Open Networking Foundation
#
# 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.
"""
OpenFlow Test Framework
Controller class
Provide the interface to the control channel to the switch under test.
Class inherits from thread so as to run in background allowing
asynchronous callbacks (if needed, not required). Also supports
polling.
The controller thread maintains a queue. Incoming messages that
are not handled by a callback function are placed in this queue for
poll calls.
Callbacks and polling support specifying the message type
@todo Support transaction semantics via xid
@todo Support select and listen on an administrative socket (or
use a timeout to support clean shutdown).
Currently only one connection is accepted during the life of
the controller. There seems
to be no clean way to interrupt an accept call. Using select that also listens
on an administrative socket and can shut down the socket might work.
"""
import sys
import os
import socket
import time
import struct
import select
import logging
from threading import Thread
from threading import Lock
from threading import Condition
import ofutils
import loxi
# Configured openflow version
import ofp as cfg_ofp
FILTER = ''.join( [ (len( repr( chr( x ) ) ) == 3) and chr( x ) or '.'
for x in range( 256 ) ] )
def hex_dump_buffer( src, length=16 ):
"""
Convert src to a hex dump string and return the string
@param src The source buffer
@param length The number of bytes shown in each line
@returns A string showing the hex dump
"""
result = [ "\n" ]
for i in xrange( 0, len( src ), length ):
chars = src[ i:i + length ]
hex = ' '.join( [ "%02x" % ord( x ) for x in chars ] )
printable = ''.join( [ "%s" % ((ord( x ) <= 127 and
FILTER[ ord( x ) ]) or '.') for x in
chars ] )
result.append( "%04x %-*s %s\n" % (i, length * 3, hex, printable) )
return ''.join( result )
##@todo Find a better home for these identifiers (controller)
RCV_SIZE_DEFAULT = 32768
LISTEN_QUEUE_SIZE = 1
class Controller( Thread ):
"""
Class abstracting the control interface to the switch.
For receiving messages, two mechanism will be implemented. First,
query the interface with poll. Second, register to have a
function called by message type. The callback is passed the
message type as well as the raw packet (or message object)
One of the main purposes of this object is to translate between network
and host byte order. 'Above' this object, things should be in host
byte order.
@todo Consider using SocketServer for listening socket
@todo Test transaction code
@var rcv_size The receive size to use for receive calls
@var max_pkts The max size of the receive queue
@var keep_alive If true, listen for echo requests and respond w/
echo replies
@var initial_hello If true, will send a hello message immediately
upon connecting to the switch
@var switch If not None, do an active connection to the switch
@var host The host to use for connect
@var port The port to connect on
@var packets_total Total number of packets received
@var packets_expired Number of packets popped from queue as queue full
@var packets_handled Number of packets handled by something
@var dbg_state Debug indication of state
"""
def __init__( self, switch=None, host='127.0.0.1', port=6653, max_pkts=1024,
force=False ):
Thread.__init__( self )
# Socket related
self.rcv_size = RCV_SIZE_DEFAULT
self.listen_socket = None
self.switch_socket = None
self.switch_addr = None
self.connect_cv = Condition( )
self.message_cv = Condition( )
self.tx_lock = Lock( )
# Used to wake up the event loop from another thread
self.waker = ofutils.EventDescriptor( )
# Counters
self.socket_errors = 0
self.parse_errors = 0
self.packets_total = 0
self.packets_expired = 0
self.packets_handled = 0
self.poll_discards = 0
# State
self.sync = Lock( )
self.handlers = { }
self.keep_alive = False
self.active = True
self.initial_hello = True
# OpenFlow message/packet queue
# Protected by the packets_cv lock / condition variable
self.packets = [ ]
self.packets_cv = Condition( )
self.packet_in_count = 0
# Settings
self.max_pkts = max_pkts
self.switch = switch
self.passive = not self.switch
self.force = force
self.host = host
self.port = port
self.dbg_state = "init"
self.logger = logging.getLogger( "controller" )
self.filter_packet_in = False # Drop "excessive" packet ins
self.pkt_in_run = 0 # Count on run of packet ins
self.pkt_in_filter_limit = 50 # Count on run of packet ins
self.pkt_in_dropped = 0 # Total dropped packet ins
self.transact_to = 15 # Transact timeout default value; add to config
# Transaction and message type waiting variables
# xid_cv: Condition variable (semaphore) for packet waiters
# xid: Transaction ID being waited on
# xid_response: Transaction response message
self.xid_cv = Condition( )
self.xid = None
self.xid_response = None
self.buffered_input = ""
# Create listen socket
if self.passive:
self.logger.info( "Create/listen at " + self.host + ":" +
str( self.port ) )
ai = socket.getaddrinfo( self.host, self.port, socket.AF_UNSPEC,
socket.SOCK_STREAM, 0, socket.AI_PASSIVE )
# Use first returned addrinfo
(family, socktype, proto, name, sockaddr) = ai[ 0 ]
self.listen_socket = socket.socket( family, socktype )
self.listen_socket.setsockopt( socket.SOL_SOCKET,
socket.SO_REUSEADDR, 1 )
self.listen_socket.bind( sockaddr )
self.listen_socket.listen( LISTEN_QUEUE_SIZE )
def filter_packet( self, rawmsg, hdr ):
"""
Check if packet should be filtered
Currently filters packet in messages
@return Boolean, True if packet should be dropped
"""
# XXX didn't actually check for packet-in...
return False
# Add check for packet in and rate limit
if self.filter_packet_in:
# If we were dropping packets, report number dropped
# TODO dont drop expected packet ins
if self.pkt_in_run > self.pkt_in_filter_limit:
self.logger.debug( "Dropped %d packet ins (%d total)"
% ((self.pkt_in_run -
self.pkt_in_filter_limit),
self.pkt_in_dropped) )
self.pkt_in_run = 0
return False
def _pkt_handle( self, pkt ):
"""
Check for all packet handling conditions
Parse and verify message
Check if XID matches something waiting
Check if message is being expected for a poll operation
Check if keep alive is on and message is an echo request
Check if any registered handler wants the packet
Enqueue if none of those conditions is met
an echo request in case keep_alive is true, followed by
registered message handlers.
@param pkt The raw packet (string) which may contain multiple OF msgs
"""
# snag any left over data from last read()
pkt = self.buffered_input + pkt
self.buffered_input = ""
# Process each of the OF msgs inside the pkt
offset = 0
while offset < len( pkt ):
if offset + 8 > len( pkt ):
break
# Parse the header to get type
hdr_version, hdr_type, hdr_length, hdr_xid = cfg_ofp.message.parse_header(
pkt[ offset: ] )
# Use loxi to resolve to ofp of matching version
ofp = loxi.protocol( hdr_version )
# Extract the raw message bytes
if (offset + hdr_length) > len( pkt ):
break
rawmsg = pkt[ offset: offset + hdr_length ]
offset += hdr_length
# if self.filter_packet(rawmsg, hdr):
# continue
msg = ofp.message.parse_message( rawmsg )
if not msg:
self.parse_errors += 1
self.logger.warn( "Could not parse message" )
continue
self.logger.debug( "Msg in: version %d class %s len %d xid %d",
hdr_version, type( msg ).__name__, hdr_length,
hdr_xid )
with self.sync:
# Check if transaction is waiting
with self.xid_cv:
if self.xid and hdr_xid == self.xid:
self.logger.debug(
"Matched expected XID " + str( hdr_xid ) )
self.xid_response = (msg, rawmsg)
self.xid = None
self.xid_cv.notify( )
continue
# Check if keep alive is set; if so, respond to echo requests
if self.keep_alive:
if hdr_type == ofp.OFPT_ECHO_REQUEST:
self.logger.debug( "Responding to echo request" )
rep = ofp.message.echo_reply( )
rep.xid = hdr_xid
# Ignoring additional data
self.message_send( rep )
continue
# Generalize to counters for all packet types?
if msg.type == ofp.OFPT_PACKET_IN:
self.packet_in_count += 1
# Log error messages
if isinstance( msg, ofp.message.error_msg ):
# pylint: disable=E1103
if msg.err_type in ofp.ofp_error_type_map:
type_str = ofp.ofp_error_type_map[ msg.err_type ]
if msg.err_type == ofp.OFPET_HELLO_FAILED:
code_map = ofp.ofp_hello_failed_code_map
elif msg.err_type == ofp.OFPET_BAD_REQUEST:
code_map = ofp.ofp_bad_request_code_map
elif msg.err_type == ofp.OFPET_BAD_ACTION:
code_map = ofp.ofp_bad_action_code_map
elif msg.err_type == ofp.OFPET_FLOW_MOD_FAILED:
code_map = ofp.ofp_flow_mod_failed_code_map
elif msg.err_type == ofp.OFPET_PORT_MOD_FAILED:
code_map = ofp.ofp_port_mod_failed_code_map
elif msg.err_type == ofp.OFPET_QUEUE_OP_FAILED:
code_map = ofp.ofp_queue_op_failed_code_map
else:
code_map = None
if code_map and msg.code in code_map:
code_str = code_map[ msg.code ]
else:
code_str = "unknown"
else:
type_str = "unknown"
code_str = "unknown"
self.logger.warn(
"Received error message: xid=%d type=%s (%d) code=%s (%d)",
hdr_xid, type_str, msg.err_type, code_str,
msg.code )
# Now check for message handlers; preference is given to
# handlers for a specific packet
handled = False
if hdr_type in self.handlers.keys( ):
handled = self.handlers[ hdr_type ]( self, msg, rawmsg )
if not handled and ("all" in self.handlers.keys( )):
handled = self.handlers[ "all" ]( self, msg, rawmsg )
if not handled: # Not handled, enqueue
with self.packets_cv:
if len( self.packets ) >= self.max_pkts:
self.packets.pop( 0 )
self.packets_expired += 1
self.packets.append( (msg, rawmsg) )
self.packets_cv.notify_all( )
self.packets_total += 1
else:
self.packets_handled += 1
self.logger.debug( "Message handled by callback" )
# end of 'while offset < len(pkt)'
# note that if offset = len(pkt), this is
# appends a harmless empty string
self.buffered_input += pkt[ offset: ]
def _socket_ready_handle( self, s ):
"""
Handle an input-ready socket
@param s The socket object that is ready
@returns 0 on success, -1 on error
"""
if self.passive and s and s == self.listen_socket:
if self.switch_socket:
self.logger.warning(
"Ignoring incoming connection; already connected to switch" )
(sock, addr) = self.listen_socket.accept( )
sock.close( )
return 0
try:
(sock, addr) = self.listen_socket.accept( )
except:
self.logger.warning( "Error on listen socket accept" )
return -1
self.logger.info( self.host + ":" + str(
self.port ) + ": Incoming connection from " + str( addr ) )
with self.connect_cv:
(self.switch_socket, self.switch_addr) = (sock, addr)
self.switch_socket.setsockopt( socket.IPPROTO_TCP,
socket.TCP_NODELAY, True )
if self.initial_hello:
self.message_send( cfg_ofp.message.hello( ) )
self.connect_cv.notify( ) # Notify anyone waiting
# Prevent further connections
self.listen_socket.close( )
self.listen_socket = None
elif s and s == self.switch_socket:
for idx in range( 3 ): # debug: try a couple of times
try:
pkt = self.switch_socket.recv( self.rcv_size )
except:
self.logger.warning( "Error on switch read" )
return -1
if not self.active:
return 0
if len( pkt ) == 0:
self.logger.warning( "Zero-length switch read, %d" % idx )
else:
break
if len( pkt ) == 0: # Still no packet
self.logger.warning( "Zero-length switch read; closing cxn" )
self.logger.info( str( self ) )
return -1
self._pkt_handle( pkt )
elif s and s == self.waker:
self.waker.wait( )
else:
self.logger.error( "Unknown socket ready: " + str( s ) )
return -1
return 0
def active_connect( self ):
"""
Actively connect to a switch IP addr
"""
try:
self.logger.info( "Trying active connection to %s" % self.switch )
soc = socket.socket( socket.AF_INET, socket.SOCK_STREAM )
soc.connect( (self.switch, self.port) )
self.logger.info( "Connected to " + self.switch + " on " +
str( self.port ) )
soc.setsockopt( socket.IPPROTO_TCP, socket.TCP_NODELAY, True )
self.switch_addr = (self.switch, self.port)
return soc
except (StandardError, socket.error), e:
self.logger.error( "Could not connect to %s at %d:: %s" %
(self.switch, self.port, str( e )) )
return None
def wakeup( self ):
"""
Wake up the event loop, presumably from another thread.
"""
self.waker.notify( )
def sockets( self ):
"""
Return list of sockets to select on.
"""
socs = [ self.listen_socket, self.switch_socket, self.waker ]
return [ x for x in socs if x ]
def run( self ):
"""
Activity function for class
Assumes connection to switch already exists. Listens on
switch_socket for messages until an error (or zero len pkt)
occurs.
When there is a message on the socket, check for handlers; queue the
packet if no one handles the packet.
See note for controller describing the limitation of a single
connection for now.
"""
self.dbg_state = "running"
while self.active:
try:
sel_in, sel_out, sel_err = \
select.select( self.sockets( ), [ ], self.sockets( ), 1 )
except:
print sys.exc_info( )
self.logger.error( "Select error, disconnecting" )
self.disconnect( )
for s in sel_err:
self.logger.error(
"Got socket error on: " + str( s ) + ", disconnecting" )
self.disconnect( )
for s in sel_in:
if self._socket_ready_handle( s ) == -1:
self.disconnect( )
# End of main loop
self.dbg_state = "closing"
self.logger.info( "Exiting controller thread" )
self.shutdown( )
def connect( self, timeout=-1 ):
"""
Connect to the switch
@param timeout Block for up to timeout seconds. Pass -1 for the default.
@return Boolean, True if connected
"""
if not self.passive: # Do active connection now
self.logger.info( "Attempting to connect to %s on port %s" %
(self.switch, str( self.port )) )
soc = self.active_connect( )
if soc:
self.logger.info( "Connected to %s", self.switch )
self.dbg_state = "running"
self.switch_socket = soc
self.wakeup( )
with self.connect_cv:
if self.initial_hello:
self.message_send( cfg_ofp.message.hello( ) )
self.connect_cv.notify( ) # Notify anyone waiting
else:
self.logger.error( "Could not actively connect to switch %s",
self.switch )
self.active = False
else:
with self.connect_cv:
ofutils.timed_wait( self.connect_cv, lambda: self.switch_socket,
timeout=timeout )
return self.switch_socket is not None
def disconnect( self, timeout=-1 ):
"""
If connected to a switch, disconnect.
"""
if self.switch_socket:
self.switch_socket.close( )
self.switch_socket = None
self.switch_addr = None
with self.packets_cv:
self.packets = [ ]
with self.connect_cv:
self.connect_cv.notifyAll( )
def wait_disconnected( self, timeout=-1 ):
"""
@param timeout Block for up to timeout seconds. Pass -1 for the default.
@return Boolean, True if disconnected
"""
with self.connect_cv:
ofutils.timed_wait( self.connect_cv,
lambda: True if not self.switch_socket else None,
timeout=timeout )
return self.switch_socket is None
def kill( self ):
"""
Force the controller thread to quit
"""
self.active = False
self.wakeup( )
self.join( )
def shutdown( self ):
"""
Shutdown the controller closing all sockets
@todo Might want to synchronize shutdown with self.sync...
"""
self.active = False
try:
self.switch_socket.shutdown( socket.SHUT_RDWR )
except:
self.logger.info( "Ignoring switch soc shutdown error" )
self.switch_socket = None
try:
self.listen_socket.shutdown( socket.SHUT_RDWR )
except:
self.logger.info( "Ignoring listen soc shutdown error" )
self.listen_socket = None
# Wakeup condition variables on which controller may be wait
with self.xid_cv:
self.xid_cv.notifyAll( )
with self.connect_cv:
self.connect_cv.notifyAll( )
self.wakeup( )
self.dbg_state = "down"
def register( self, msg_type, handler ):
"""
Register a callback to receive a specific message type.
Only one handler may be registered for a given message type.
WARNING: A lock is held during the handler call back, so
the handler should not make any blocking calls
@param msg_type The type of message to receive. May be DEFAULT
for all non-handled packets. The special type, the string "all"
will send all packets to the handler.
@param handler The function to call when a message of the given
type is received.
"""
# Should check type is valid
if not handler and msg_type in self.handlers.keys( ):
del self.handlers[ msg_type ]
return
self.handlers[ msg_type ] = handler
def poll( self, exp_msg=None, timeout=-1 ):
"""
Wait for the next OF message received from the switch.
@param exp_msg If set, return only when this type of message
is received (unless timeout occurs).
@param timeout Maximum number of seconds to wait for the message.
Pass -1 for the default timeout.
@retval A pair (msg, pkt) where msg is a message object and pkt
the string representing the packet as received from the socket.
This allows additional parsing by the receiver if necessary.
The data members in the message are in host endian order.
If an error occurs, (None, None) is returned
"""
if exp_msg is None:
self.logger.warn( "DEPRECATED polling for any message class" )
klass = None
elif isinstance( exp_msg, int ):
klass = cfg_ofp.message.message.subtypes[ exp_msg ]
elif issubclass( exp_msg, loxi.OFObject ):
klass = exp_msg
else:
raise ValueError( "Unexpected exp_msg argument %r" % exp_msg )
self.logger.debug( "Polling for %s", klass.__name__ )
# Take the packet from the queue
def grab( ):
for i, (msg, pkt) in enumerate( self.packets ):
if klass is None or isinstance( msg, klass ):
self.logger.debug( "Got %s message",
msg.__class__.__name__ )
return self.packets.pop( i )
# Not found
self.logger.debug( "%s message not in queue", klass.__name__ )
return None
with self.packets_cv:
ret = ofutils.timed_wait( self.packets_cv, grab, timeout=timeout )
if ret != None:
(msg, pkt) = ret
return (msg, pkt)
else:
return (None, None)
def transact( self, msg, timeout=-1 ):
"""
Run a message transaction with the switch
Send the message in msg and wait for a reply with a matching
transaction id. Transactions have the highest priority in
received message handling.
@param msg The message object to send; must not be a string
@param timeout The timeout in seconds; if -1 use default.
"""
if msg.xid == None:
msg.xid = ofutils.gen_xid( )
self.logger.debug( "Running transaction %d" % msg.xid )
with self.xid_cv:
if self.xid:
self.logger.error( "Can only run one transaction at a time" )
return (None, None)
self.xid = msg.xid
self.xid_response = None
self.message_send( msg )
self.logger.debug( "Waiting for transaction %d" % msg.xid )
ofutils.timed_wait( self.xid_cv, lambda: self.xid_response,
timeout=timeout )
if self.xid_response:
(resp, pkt) = self.xid_response
self.xid_response = None
else:
(resp, pkt) = (None, None)
if resp is None:
self.logger.warning( "No response for xid " + str( self.xid ) )
return (resp, pkt)
def message_send( self, msg ):
"""
Send the message to the switch
@param msg A string or OpenFlow message object to be forwarded to
the switch.
"""
if not self.switch_socket:
# Sending a string indicates the message is ready to go
raise Exception( "no socket" )
if msg.xid == None:
msg.xid = ofutils.gen_xid( )
outpkt = msg.pack( )
self.logger.debug( "Msg out: version %d class %s len %d xid %d",
msg.version, type( msg ).__name__, len( outpkt ),
msg.xid )
with self.tx_lock:
if self.switch_socket.sendall( outpkt ) is not None:
raise AssertionError( "failed to send message to switch" )
return 0 # for backwards compatibility
def clear_queue( self ):
"""
Clear the input queue and report the number of messages
that were in it
"""
enqueued_pkt_count = len( self.packets )
with self.packets_cv:
self.packets = [ ]
return enqueued_pkt_count
def __str__( self ):
string = "Controller:\n"
string += " state " + self.dbg_state + "\n"
string += " switch_addr " + str( self.switch_addr ) + "\n"
string += " pending pkts " + str( len( self.packets ) ) + "\n"
string += " total pkts " + str( self.packets_total ) + "\n"
string += " expired pkts " + str( self.packets_expired ) + "\n"
string += " handled pkts " + str( self.packets_handled ) + "\n"
string += " poll discards " + str( self.poll_discards ) + "\n"
string += " parse errors " + str( self.parse_errors ) + "\n"
string += " sock errrors " + str( self.socket_errors ) + "\n"
string += " max pkts " + str( self.max_pkts ) + "\n"
string += " target switch " + str( self.switch ) + "\n"
string += " host " + str( self.host ) + "\n"
string += " port " + str( self.port ) + "\n"
string += " keep_alive " + str( self.keep_alive ) + "\n"
string += " pkt_in_run " + str( self.pkt_in_run ) + "\n"
string += " pkt_in_dropped " + str( self.pkt_in_dropped ) + "\n"
return string
def show( self ):
print str( self )
def sample_handler( controller, msg, pkt ):
"""
Sample message handler
This is the prototype for functions registered with the controller
class for packet reception
@param controller The controller calling the handler
@param msg The parsed message object
@param pkt The raw packet that was received on the socket. This is
in case the packet contains extra unparsed data.
@returns Boolean value indicating if the packet was handled. If
not handled, the packet is placed in the queue for pollers to received
"""
pass