├── README.md ├── mininet_topologia.py └── multipath.py /README.md: -------------------------------------------------------------------------------- 1 | # SDN Ryu-Controller -- Load-Balancing with Dynamic-Routing 2 | SDN Ryu controller with Load Balancing and dynamic routing 3 | 4 | OpenFlow version used: OpenFlow 1.3 5 | Description: Project during which I have created Ryu controller which performs BFS algorithm to find best paths, based on traffic flowing through links optimal path is being choosen from possible paths. The costs are being calculated in the background (action performed by thread) and optimal path is being updated every second based on the gathered stats. Discover of topology is done automatically so we don't have to have specially prepared topology. 6 | 7 | Based on: 8 | https://ryu.readthedocs.io/en/latest/ryu_app_api.html 9 | https://github.com/osrg/ryu/blob/master/ryu/app/simple_switch.py 10 | https://github.com/wildan2711/multipath/blob/master/ryu_multipath.py 11 | -------------------------------------------------------------------------------- /mininet_topologia.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | 'This is test topology for SSP project' 4 | 5 | import sys 6 | 7 | from mininet.node import Controller, OVSKernelSwitch, RemoteController 8 | from mininet.log import setLogLevel, info 9 | from mininet.cli import CLI 10 | from mininet.net import Mininet 11 | from time import sleep 12 | 13 | def topology(): 14 | 'Create a network and controller' 15 | net = Mininet(controller=RemoteController, switch=OVSKernelSwitch) 16 | 17 | c0 = net.addController('c0', controller=RemoteController, ip='127.0.0.1', port=6653) 18 | 19 | info("*** Creating nodes\n") 20 | 21 | h1 = net.addHost('h1', ip='10.0.0.1/24', position='10,10,0') 22 | h2 = net.addHost('h2', ip='10.0.0.2/24', position='20,10,0') 23 | 24 | sw1 = net.addSwitch('sw1', protocols="OpenFlow13", position='12,10,0') 25 | sw2 = net.addSwitch('sw2', protocols="OpenFlow13", position='15,20,0') 26 | sw3 = net.addSwitch('sw3', protocols="OpenFlow13", position='18,10,0') 27 | sw4 = net.addSwitch('sw4', protocols="OpenFlow13", position='14,10,0') 28 | sw5 = net.addSwitch('sw5', protocols="OpenFlow13", position='16,10,0') 29 | sw6 = net.addSwitch('sw6', protocols="OpenFlow13", position='14,0,0') 30 | sw7 = net.addSwitch('sw7', protocols="OpenFlow13", position='16,0,0') 31 | 32 | 33 | info("*** Adding Link\n") 34 | net.addLink(h1, sw1) 35 | net.addLink(sw1, sw2) 36 | net.addLink(sw1, sw4) 37 | net.addLink(sw1, sw6) 38 | net.addLink(sw2, sw3) 39 | net.addLink(sw4, sw5) 40 | net.addLink(sw5, sw3) 41 | net.addLink(sw6, sw7) 42 | net.addLink(sw7, sw3) 43 | net.addLink(sw3, h2) 44 | 45 | 46 | info("*** Starting network\n") 47 | net.build() 48 | c0.start() 49 | sw1.start([c0]) 50 | sw2.start([c0]) 51 | sw3.start([c0]) 52 | sw4.start([c0]) 53 | sw5.start([c0]) 54 | sw6.start([c0]) 55 | sw7.start([c0]) 56 | 57 | 58 | net.pingFull() 59 | 60 | info("*** Running CLI\n") 61 | CLI( net ) 62 | 63 | info("*** Stopping network\n") 64 | net.stop() 65 | 66 | 67 | if __name__ == '__main__': 68 | setLogLevel('info') 69 | topology() 70 | -------------------------------------------------------------------------------- /multipath.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from ryu.base import app_manager 4 | from ryu.controller import mac_to_port 5 | from ryu.controller import ofp_event 6 | from ryu.controller.handler import CONFIG_DISPATCHER, MAIN_DISPATCHER 7 | from ryu.controller.handler import set_ev_cls 8 | from ryu.ofproto import ofproto_v1_3 9 | from ryu.lib.mac import haddr_to_bin 10 | from ryu.lib.packet import packet 11 | from ryu.lib.packet import arp 12 | from ryu.lib.packet import ethernet 13 | from ryu.lib.packet import ipv4 14 | from ryu.lib.packet import ipv6 15 | from ryu.lib.packet import ether_types 16 | from ryu.lib.packet import udp 17 | from ryu.lib.packet import tcp 18 | from ryu.lib import mac, ip 19 | from ryu.lib import hub 20 | from ryu.ofproto import inet 21 | from ryu.topology.api import get_switch, get_link, get_host 22 | from ryu.app.wsgi import ControllerBase 23 | from ryu.topology import event, switches 24 | 25 | from collections import defaultdict 26 | from operator import itemgetter 27 | from dataclasses import dataclass 28 | import heapq 29 | 30 | import threading 31 | import os 32 | import random 33 | import time 34 | 35 | #Cisco Dijkstra values: 36 | REFERENCE_BW = 10000000 37 | DEFAULT_BW = 10000000 38 | MAX_PATHS = 1 39 | 40 | @dataclass 41 | class Paths: 42 | ''' Paths container''' 43 | path: list() 44 | cost: float 45 | 46 | 47 | class Controller13(app_manager.RyuApp): 48 | OFP_VERSIONS = [ofproto_v1_3.OFP_VERSION] 49 | 50 | 51 | def __init__(self, *args, **kwargs): 52 | super(Controller13, self).__init__(*args, **kwargs) 53 | self.mac_to_port = {} 54 | # W ponizszej zmiennej przechowujemy slownik w ktorym wartosciami sa Set'y aby uniknac powtorzen ! 55 | self.neigh = defaultdict(dict) #Sasiedzi, struktura jest taka: {SwithId: {Neighbour1:Port_Switcha,Neighbour2:Port_Switcha}} 56 | self.bw = defaultdict(lambda: defaultdict( lambda: DEFAULT_BW)) #Struktura taka sama jak poprzednio, odwolanie do dwoch wartosci tylko tym razem [switch][port] 57 | self.prev_bytes = defaultdict(lambda: defaultdict( lambda: 0)) #Struktura taka sama jak poprzednio, odwolanie do dwoch wartosci tylko tym razem [switch][port] 58 | self.hosts = {} #Slownik przechowujacy dane w formacie EthernetSource:(DPID_of_switch, In_Port) 59 | self.switches = [] # Lista switch'y 60 | self.arp_table = {} # Mapowanie MAC:PORT 61 | self.path_table = {} # Aktualna sciezka struktura (src, in_port, dst, out_port):Path 62 | self.paths_table = {} # Mozliwe sciezki struktura (src, in_port, dst, out_port):Paths 63 | self.path_with_ports_table = {} # Aktualna (optymalna) sciezka z portami struktura (src, in_port, dst, out_port):Path_with_ports 64 | self.datapath_list = {} # Slownik SwitchDPID:SwitchDP 65 | self.path_calculation_keeper = [] #Lista zawierajaca tuple'a (src, in_port, dst, out_port) 66 | 67 | def find_path_cost(self,path): 68 | ''' arg path is an list with all nodes in our route ''' 69 | path_cost = [] 70 | for i in range(len(path) - 1): 71 | port1 = self.neigh[path[i]][path[i + 1]] 72 | #port2 = self.neigh[path[i + 1]][path[i]] 73 | bandwidth_between_two_nodes = self.bw[path[i]][port1] 74 | #path_cost.append(REFERENCE_BW / bandwidth_between_two_nodes) 75 | path_cost.append(bandwidth_between_two_nodes) 76 | return sum(path_cost) 77 | 78 | def find_paths_and_costs(self, src, dst): 79 | ''' 80 | Implementation of Breath-First Search Algorithm (BFS) 81 | Output of this function returns an list on class Paths objects 82 | ''' 83 | if src == dst: 84 | # print("Source is the same as destination ! ") 85 | return [Paths(src,0)] 86 | queue = [(src, [src])] 87 | possible_paths = list() # Jesli uzywamy generatora to zakomentowac. 88 | while queue: 89 | (edge, path) = queue.pop() 90 | for vortex in set(self.neigh[edge]) - set(path): 91 | if vortex == dst: 92 | path_to_dst = path + [vortex] 93 | cost_of_path = self.find_path_cost(path_to_dst) 94 | possible_paths.append(Paths(path_to_dst, cost_of_path)) 95 | else: 96 | queue.append((vortex, path + [vortex])) 97 | return possible_paths #Zakomentowac jesli uzywamy generatora. 98 | 99 | def find_n_optimal_paths(self, paths, number_of_optimal_paths = MAX_PATHS): 100 | '''arg Paths is an list containing lists of possible paths''' 101 | costs = [path.cost for path in paths] 102 | optimal_paths_indexes = list(map(costs.index, heapq.nsmallest(number_of_optimal_paths,costs))) 103 | optimal_paths = [paths[op_index] for op_index in optimal_paths_indexes] 104 | return optimal_paths 105 | 106 | def add_ports_to_paths(self, paths, first_port, last_port): 107 | ''' 108 | Add the ports to all switches including hosts 109 | ''' 110 | paths_n_ports = list() 111 | bar = {} # Slownik o strukturze switchDPID:(Ingress_Port, Egress_Port) 112 | in_port = first_port 113 | for s1, s2 in zip(paths[0].path[:-1], paths[0].path[1:]): 114 | out_port = self.neigh[s1][s2] 115 | bar[s1] = (in_port, out_port) 116 | in_port = self.neigh[s2][s1] 117 | bar[paths[0].path[-1]] = (in_port, last_port) 118 | paths_n_ports.append(bar) 119 | return paths_n_ports 120 | 121 | def install_paths(self, src, first_port, dst, last_port, ip_src, ip_dst, type, pkt): 122 | ''' Instalacja sciezek ''' 123 | # self.topology_discover(src, first_port, dst, last_port) 124 | 125 | if (src, first_port, dst, last_port) not in self.path_calculation_keeper: 126 | self.path_calculation_keeper.append((src, first_port, dst, last_port)) 127 | self.topology_discover(src, first_port, dst, last_port) 128 | self.topology_discover(dst, last_port, src, first_port) 129 | 130 | 131 | for node in self.path_table[(src, first_port, dst, last_port)][0].path: 132 | 133 | dp = self.datapath_list[node] 134 | ofp = dp.ofproto 135 | ofp_parser = dp.ofproto_parser 136 | 137 | actions = [] 138 | 139 | in_port = self.path_with_ports_table[(src, first_port, dst, last_port)][0][node][0] 140 | out_port = self.path_with_ports_table[(src, first_port, dst, last_port)][0][node][1] 141 | 142 | actions = [ofp_parser.OFPActionOutput(out_port)] 143 | 144 | if type == 'UDP': 145 | nw = pkt.get_protocol(ipv4.ipv4) 146 | l4 = pkt.get_protocol(udp.udp) 147 | 148 | match = ofp_parser.OFPMatch(in_port = in_port, 149 | eth_type=ether_types.ETH_TYPE_IP, 150 | ipv4_src=ip_src, 151 | ipv4_dst = ip_dst, 152 | ip_proto=inet.IPPROTO_UDP, 153 | udp_src = l4.src_port, 154 | udp_dst = l4.dst_port) 155 | 156 | self.logger.info(f"Installed path in switch: {node} out port: {out_port} in port: {in_port} ") 157 | 158 | self.add_flow(dp, 33333, match, actions, 10) 159 | self.logger.info("UDP Flow added ! ") 160 | 161 | elif type == 'TCP': 162 | 163 | nw = pkt.get_protocol(ipv4.ipv4) 164 | l4 = pkt.get_protocol(tcp.tcp) 165 | 166 | match = ofp_parser.OFPMatch(in_port = in_port, 167 | eth_type=ether_types.ETH_TYPE_IP, 168 | ipv4_src=ip_src, 169 | ipv4_dst = ip_dst, 170 | ip_proto=inet.IPPROTO_TCP, 171 | tcp_src = l4.src_port, 172 | tcp_dst = l4.dst_port) 173 | 174 | self.logger.info(f"Installed path in switch: {node} out port: {out_port} in port: {in_port} ") 175 | 176 | self.add_flow(dp, 44444, match, actions, 10) 177 | self.logger.info("TCP Flow added ! ") 178 | 179 | elif type == 'ICMP': 180 | 181 | nw = pkt.get_protocol(ipv4.ipv4) 182 | 183 | match = ofp_parser.OFPMatch(in_port=in_port, 184 | eth_type=ether_types.ETH_TYPE_IP, 185 | ipv4_src=ip_src, 186 | ipv4_dst = ip_dst, 187 | ip_proto=inet.IPPROTO_ICMP) 188 | 189 | self.logger.info(f"Installed path in switch: {node} out port: {out_port} in port: {in_port} ") 190 | 191 | 192 | self.add_flow(dp, 22222, match, actions, 10) 193 | self.logger.info("ICMP Flow added ! ") 194 | 195 | elif type == 'ARP': 196 | match_arp = ofp_parser.OFPMatch(in_port = in_port, 197 | eth_type=ether_types.ETH_TYPE_ARP, 198 | arp_spa=ip_src, 199 | arp_tpa=ip_dst) 200 | 201 | self.logger.info(f"Install path in switch: {node} out port: {out_port} in port: {in_port} ") 202 | 203 | self.add_flow(dp, 1, match_arp, actions, 10) 204 | self.logger.info("ARP Flow added ! ") 205 | 206 | #return self.path_with_ports_table[0][src][1] 207 | return self.path_with_ports_table[(src, first_port, dst, last_port)][0][src][1] 208 | 209 | def add_flow(self, datapath, priority, match, actions, idle_timeout, buffer_id = None): 210 | ''' Method Provided by the source Ryu library. ''' 211 | 212 | ofproto = datapath.ofproto #Definicja biblioteki dla uzytek wersji OpenFlow. 213 | parser = datapath.ofproto_parser 214 | 215 | inst = [parser.OFPInstructionActions(ofproto.OFPIT_APPLY_ACTIONS, 216 | actions)] 217 | if buffer_id: 218 | mod = parser.OFPFlowMod(datapath=datapath, buffer_id=buffer_id, 219 | priority=priority, match=match, idle_timeout = idle_timeout, 220 | instructions=inst) 221 | else: 222 | mod = parser.OFPFlowMod(datapath=datapath, priority=priority, 223 | match=match, idle_timeout = idle_timeout, instructions=inst) 224 | datapath.send_msg(mod) 225 | 226 | def run_check(self, ofp_parser, dp): 227 | ''' Co sekunde watek wypytuje switche o status portow i wysylany jest PortStatsReq''' 228 | threading.Timer(1.0, self.run_check, args=(ofp_parser, dp)).start() 229 | 230 | req = ofp_parser.OFPPortStatsRequest(dp) 231 | #self.logger.info(f"Port Stats Request has been sent for sw: {dp} !") 232 | dp.send_msg(req) 233 | 234 | def topology_discover(self, src, first_port, dst, last_port): 235 | ''' Obliczanie optymalnej sciezki dla zadanych parametrow + przypisanie portow ''' 236 | threading.Timer(1.0, self.topology_discover, args=(src, first_port, dst, last_port)).start() 237 | paths = self.find_paths_and_costs(src, dst) 238 | path = self.find_n_optimal_paths(paths) 239 | path_with_port = self.add_ports_to_paths(path, first_port, last_port) 240 | 241 | self.logger.info(f"Possible paths: {paths}") 242 | self.logger.info(f"Optimal Path with port: {path_with_port}") 243 | 244 | self.paths_table[(src, first_port, dst, last_port)] = paths 245 | self.path_table[(src, first_port, dst, last_port)] = path 246 | self.path_with_ports_table[(src, first_port, dst, last_port)] = path_with_port 247 | 248 | 249 | @set_ev_cls(ofp_event.EventOFPPacketIn, MAIN_DISPATCHER) 250 | def _packet_in_handler(self, ev): 251 | if ev.msg.msg_len < ev.msg.total_len: 252 | self.logger.debug("packet truncated: only %s of %s bytes", ev.msg.msg_len, ev.msg.total_len) 253 | msg = ev.msg 254 | datapath = msg.datapath 255 | ofproto = datapath.ofproto 256 | parser = datapath.ofproto_parser 257 | in_port = msg.match['in_port'] 258 | 259 | pkt = packet.Packet(msg.data) 260 | eth = pkt.get_protocols(ethernet.ethernet)[0] 261 | arp_pkt = pkt.get_protocol(arp.arp) 262 | ip_pkt = pkt.get_protocol(ipv4.ipv4) 263 | 264 | if eth.ethertype == ether_types.ETH_TYPE_LLDP: 265 | # ignore lldp packet 266 | return 267 | 268 | dst = eth.dst 269 | src = eth.src 270 | dpid = datapath.id 271 | 272 | if src not in self.hosts: 273 | self.hosts[src] = (dpid, in_port) 274 | 275 | out_port = ofproto.OFPP_FLOOD 276 | 277 | if eth.ethertype == ether_types.ETH_TYPE_IP: 278 | nw = pkt.get_protocol(ipv4.ipv4) 279 | if nw.proto == inet.IPPROTO_UDP: 280 | l4 = pkt.get_protocol(udp.udp) 281 | elif nw.proto == inet.IPPROTO_TCP: 282 | l4 = pkt.get_protocol(tcp.tcp) 283 | 284 | if eth.ethertype == ether_types.ETH_TYPE_IP and nw.proto == inet.IPPROTO_UDP: 285 | src_ip = nw.src 286 | dst_ip = nw.dst 287 | 288 | self.arp_table[src_ip] = src 289 | h1 = self.hosts[src] 290 | h2 = self.hosts[dst] 291 | 292 | self.logger.info(f" IP Proto UDP from: {nw.src} to: {nw.dst}") 293 | 294 | out_port = self.install_paths(h1[0], h1[1], h2[0], h2[1], src_ip, dst_ip, 'UDP', pkt) 295 | self.install_paths(h2[0], h2[1], h1[0], h1[1], dst_ip, src_ip, 'UDP', pkt) 296 | 297 | 298 | elif eth.ethertype == ether_types.ETH_TYPE_IP and nw.proto == inet.IPPROTO_TCP: 299 | src_ip = nw.src 300 | dst_ip = nw.dst 301 | 302 | self.arp_table[src_ip] = src 303 | h1 = self.hosts[src] 304 | h2 = self.hosts[dst] 305 | 306 | self.logger.info(f" IP Proto TCP from: {nw.src} to: {nw.dst}") 307 | 308 | out_port = self.install_paths(h1[0], h1[1], h2[0], h2[1], src_ip, dst_ip, 'TCP', pkt) 309 | self.install_paths(h2[0], h2[1], h1[0], h1[1], dst_ip, src_ip, 'TCP', pkt) 310 | 311 | elif eth.ethertype == ether_types.ETH_TYPE_IP and nw.proto == inet.IPPROTO_ICMP: 312 | src_ip = nw.src 313 | dst_ip = nw.dst 314 | 315 | self.arp_table[src_ip] = src 316 | h1 = self.hosts[src] 317 | h2 = self.hosts[dst] 318 | 319 | self.logger.info(f" IP Proto ICMP from: {nw.src} to: {nw.dst}") 320 | 321 | out_port = self.install_paths(h1[0], h1[1], h2[0], h2[1], src_ip, dst_ip, 'ICMP', pkt) 322 | self.install_paths(h2[0], h2[1], h1[0], h1[1], dst_ip, src_ip, 'ICMP', pkt) 323 | 324 | elif eth.ethertype == ether_types.ETH_TYPE_ARP: 325 | src_ip = arp_pkt.src_ip 326 | dst_ip = arp_pkt.dst_ip 327 | 328 | if arp_pkt.opcode == arp.ARP_REPLY: 329 | self.arp_table[src_ip] = src 330 | h1 = self.hosts[src] 331 | h2 = self.hosts[dst] 332 | 333 | self.logger.info(f" ARP Reply from: {src_ip} to: {dst_ip} H1: {h1} H2: {h2}") 334 | 335 | out_port = self.install_paths(h1[0], h1[1], h2[0], h2[1], src_ip, dst_ip, 'ARP', pkt) 336 | self.install_paths(h2[0], h2[1], h1[0], h1[1], dst_ip, src_ip, 'ARP', pkt) 337 | 338 | elif arp_pkt.opcode == arp.ARP_REQUEST: 339 | if dst_ip in self.arp_table: 340 | self.arp_table[src_ip] = src 341 | dst_mac = self.arp_table[dst_ip] 342 | h1 = self.hosts[src] 343 | h2 = self.hosts[dst_mac] 344 | 345 | self.logger.info(f" ARP Reply from: {src_ip} to: {dst_ip} H1: {h1} H2: {h2}") 346 | 347 | out_port = self.install_paths(h1[0], h1[1], h2[0], h2[1], src_ip, dst_ip, 'ARP', pkt) 348 | self.install_paths(h2[0], h2[1], h1[0], h1[1], dst_ip, src_ip, 'ARP', pkt) 349 | 350 | actions = [parser.OFPActionOutput(out_port)] 351 | 352 | data = None 353 | 354 | if msg.buffer_id == ofproto.OFP_NO_BUFFER: 355 | data = msg.data 356 | 357 | out = parser.OFPPacketOut(datapath=datapath, buffer_id=msg.buffer_id, 358 | in_port=in_port, actions=actions, data=data) 359 | datapath.send_msg(out) 360 | 361 | @set_ev_cls(ofp_event.EventOFPSwitchFeatures, CONFIG_DISPATCHER) 362 | def _switch_features_handler(self, ev): 363 | ''' 364 | To send packets for which we dont have right information to the controller 365 | Method Provided by the source Ryu library. 366 | ''' 367 | 368 | datapath = ev.msg.datapath 369 | ofproto = datapath.ofproto 370 | parser = datapath.ofproto_parser 371 | 372 | match = parser.OFPMatch() 373 | actions = [parser.OFPActionOutput(ofproto.OFPP_CONTROLLER, 374 | ofproto.OFPCML_NO_BUFFER)] 375 | self.add_flow(datapath, 0, match, actions, 10) 376 | 377 | @set_ev_cls(ofp_event.EventOFPPortStatsReply, MAIN_DISPATCHER) 378 | def _port_stats_reply_handler(self, ev): 379 | '''Reply to the OFPPortStatsRequest, visible beneath''' 380 | switch_dpid = ev.msg.datapath.id 381 | for p in ev.msg.body: 382 | self.bw[switch_dpid][p.port_no] = (p.tx_bytes - self.prev_bytes[switch_dpid][p.port_no])*8.0/1000000 #Przechowuje zajetosc portu w Mbit/s 383 | self.prev_bytes[switch_dpid][p.port_no] = p.tx_bytes 384 | 385 | 386 | #self.logger.info(f"Switch: {switch_dpid} Port: {p.port_no} Tx bytes: {p.tx_bytes} Bw: {self.bw[switch_dpid][p.port_no]}") 387 | 388 | @set_ev_cls(event.EventSwitchEnter) 389 | def switch_enter_handler(self, ev): 390 | switch_dp = ev.switch.dp 391 | switch_dpid = switch_dp.id 392 | ofp_parser = switch_dp.ofproto_parser 393 | 394 | self.logger.info(f"Switch has been plugged in PID: {switch_dpid}") 395 | 396 | if switch_dpid not in self.switches: 397 | self.datapath_list[switch_dpid] = switch_dp 398 | self.switches.append(switch_dpid) 399 | 400 | self.run_check(ofp_parser, switch_dp) #Funkcja watkowa dzialajace w tle co 1s 401 | 402 | @set_ev_cls(event.EventSwitchLeave, MAIN_DISPATCHER) 403 | def switch_leave_handler(self, ev): 404 | switch = ev.switch.dp.id 405 | if switch in self.switches: 406 | try: 407 | self.switches.remove(switch) 408 | del self.datapath_list[switch] 409 | del self.neigh[switch] 410 | except KeyError: 411 | self.logger.info(f"Switch has been already pulged off PID{switch}!") 412 | 413 | 414 | @set_ev_cls(event.EventLinkAdd, MAIN_DISPATCHER) 415 | def link_add_handler(self, ev): 416 | self.neigh[ev.link.src.dpid][ev.link.dst.dpid] = ev.link.src.port_no 417 | self.neigh[ev.link.dst.dpid][ev.link.src.dpid] = ev.link.dst.port_no 418 | self.logger.info(f"Link between switches has been established, SW1 DPID: {ev.link.src.dpid}:{ev.link.dst.port_no} SW2 DPID: {ev.link.dst.dpid}:{ev.link.dst.port_no}") 419 | 420 | @set_ev_cls(event.EventLinkDelete, MAIN_DISPATCHER) 421 | def link_delete_handler(self, ev): 422 | try: 423 | del self.neigh[ev.link.src.dpid][ev.link.dst.dpid] 424 | del self.neigh[ev.link.dst.dpid][ev.link.src.dpid] 425 | except KeyError: 426 | self.logger.info("Link has been already pluged off!") 427 | pass --------------------------------------------------------------------------------