├── .gitignore ├── LICENSE ├── README.md ├── aiobfd ├── __init__.py ├── __main__.py ├── control.py ├── packet.py ├── session.py └── transport.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── test_control.py ├── test_packet.py ├── test_session.py └── test_transport.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | pip-delete-this-directory.txt 26 | 27 | # Unit test / coverage reports 28 | .tox/ 29 | .coverage 30 | .cache 31 | nosetests.xml 32 | coverage.xml 33 | 34 | # IDEs 35 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Kris Lambrechts 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | aiobfd: Asynchronous BFD Daemon 2 | ================= 3 | Bidirectional Forwarding Detection Daemon written in Python, using [AsyncIO](https://www.python.org/dev/peps/pep-3156/). This package enables you to run the BFD protocol between the host and a neighbouring device, often a router. 4 | 5 | A common use case for aiobfd would be to pair it with a BGP speaker such as [ExaBGP](https://github.com/Exa-Networks/exabgp) for anycast loadbalancing. By establishing a BFD session alongside the BGP session it's possible to to do subsecond failure detection and fail-over. 6 | 7 | This implementation is compliant with: 8 | * [RFC5880](https://tools.ietf.org/html/rfc5880): Bidirectional Forwarding Detection (BFD) 9 | * [RFC5881](https://tools.ietf.org/html/rfc5881): Bidirectional Forwarding Detection (BFD) for IPv4 and IPv6 (Single Hop) 10 | 11 | Installation 12 | ----------------- 13 | aiobfd is available from [PyPI](https://pypi.python.org/pypi/aiobfd) and can be installed with pip. 14 | ``` 15 | pip install aiobfd 16 | ``` 17 | 18 | Running the service 19 | ------------------- 20 | After installation you can run a basic aiobfd as following. 21 | This assumes 192.168.0.2 is a locally configured IP address and 192.168.0.5 is the remote BFD speaker. 22 | ``` 23 | aiobfd 192.0.2.2 192.0.2.1 24 | ``` 25 | A more complex example over IPv6 where we agree to transmit & receive packets at 15ms intervals and use a detection multiplier of 3, resulting in a failure detection time of 45ms. 26 | ``` 27 | aiobfd 2001:db8::2 2001:db8::1 --rx-interval 15 --tx-interval 15 --detect-mult 3 28 | ``` 29 | 30 | Security considerations 31 | ----------------------- 32 | To comply with [section 5 of RFC 5881](https://tools.ietf.org/html/rfc5881#section-5) a BFD peer should drop any BFD packets with a TTL/HL of less than the maximum (255) when authentication is not used. aiobfd does not currently check the TTL/HL value on incoming packets. You should make sure that only compliant packets can reach the service. Assuming a default DROP policy an ip(6)tables rule such as these examples should achieve the desired result. 33 | ``` 34 | iptables -A INPUT -i eth0 -p udp --dport 3784 --ttl-eq 255 -j ACCEPT 35 | ip6tables -A INPUT -i eth0 -p udp --dport 3784 --hl-eq 255 -j ACCEPT 36 | ``` 37 | 38 | Questions & Answers 39 | ------------------- 40 | **Q**: Which Python versions do you support? 41 | 42 | **A**: aiobfd will run on Python 3.5+. 43 | *** 44 | **Q**: Do you support IPv6? 45 | 46 | **A**: Of course! 47 | *** 48 | **Q**: What sort of fail-over times can I achieve? 49 | 50 | **A**: In our testing we have succesfully setup and maintained BFD sessions with remote hardware routers with 10ms intervals and a detection multiplier of 3. Most software routers used in testing etc. had to use use less aggressive timers, up to around 300ms detection times. 51 | *** 52 | **Q**: Does aiobfd support BFD Demand mode? 53 | 54 | **A**: aiobfd will never request to switch to Demand mode. But it does honor a request to go into Demand mode by a remote BFD speaker. 55 | *** 56 | **Q**: Does aiobfd support the BFD Echo Function? 57 | 58 | **A**: No, aiobfd does not implement the Echo Function. As this is a pure software implemenation we see Echo support as a low priority. if you have a good use case for this, feel free to open up a GitHub issue. 59 | *** 60 | **Q**: Does aiobfd support Authentication? 61 | 62 | **A**: No, aiobfd does not support authentication at present. 63 | 64 | Other BFD implementations 65 | ------------------- 66 | 67 | * [FreeBFD](https://github.com/silpertan/FreeBFD) - a C implementation, IPv4 only 68 | * [OpenBFDD](https://github.com/dyninc/OpenBFDD) - a C implemenation using a beacon service which relies on a control utility to be configured 69 | * [GoBFD](https://github.com/jthurman42/go-bfd) - early work on a Go BFD library 70 | -------------------------------------------------------------------------------- /aiobfd/__init__.py: -------------------------------------------------------------------------------- 1 | """aiobfd: Asynchronous BFD Daemon""" 2 | # pylint: disable=I0011,W0401 3 | 4 | from .control import * # noqa: F403 5 | from .packet import * # noqa: F403 6 | from .session import * # noqa: F403 7 | from .transport import * # noqa: F403 8 | 9 | __all__ = ['control', 'packet', 'session', 'transport'] 10 | -------------------------------------------------------------------------------- /aiobfd/__main__.py: -------------------------------------------------------------------------------- 1 | """aiobfd: Asynchronous BFD Daemon""" 2 | 3 | import argparse 4 | import socket 5 | import logging 6 | import logging.handlers 7 | import sys 8 | import aiobfd 9 | 10 | _LOG_LEVELS = ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'] 11 | 12 | 13 | def parse_arguments(): 14 | """Parse the user arguments""" 15 | parser = argparse.ArgumentParser( 16 | description='Maintain a BFD session with a remote system') 17 | parser.add_argument('local', help='Local IP address or hostname') 18 | parser.add_argument('remote', help='Remote IP address or hostname') 19 | family_group = parser.add_mutually_exclusive_group() 20 | family_group.add_argument('-4', '--ipv4', action='store_const', 21 | dest='family', default=socket.AF_UNSPEC, 22 | const=socket.AF_INET, 23 | help='Force IPv4 connectivity') 24 | family_group.add_argument('-6', '--ipv6', action='store_const', 25 | dest='family', default=socket.AF_UNSPEC, 26 | const=socket.AF_INET6, 27 | help='Force IPv6 connectivity') 28 | parser.add_argument('-r', '--rx-interval', default=1000, type=int, 29 | help='Required minimum Rx interval (ms)') 30 | parser.add_argument('-t', '--tx-interval', default=1000, type=int, 31 | help='Desired minimum Tx interval (ms)') 32 | parser.add_argument('-m', '--detect-mult', default=1, type=int, 33 | help='Detection multiplier') 34 | parser.add_argument('-p', '--passive', action='store_true', 35 | help='Take a passive role in session initialization') 36 | parser.add_argument('-l', '--log-level', default='WARNING', 37 | help='Logging level', choices=_LOG_LEVELS) 38 | parser.add_argument('-o', '--no-log-to-stdout', action='store_true', 39 | help='Disable logging to stdout; will be ignored if no' 40 | ' other logging is selected.') 41 | parser.add_argument('-f', '--log-to-file', action='store_true', 42 | help='Enable logging to a file on the filesystem') 43 | parser.add_argument('-n', '--log-file', default='/var/log/aiobfd.log', 44 | help='Path on filesystem to log to, if enabled') 45 | parser.add_argument('-s', '--log-to-syslog', action='store_true', 46 | help='Enable logging to a syslog handler') 47 | parser.add_argument('-y', '--log-sock', default='/dev/log', 48 | help='Syslog socket to log to, if enabled') 49 | return parser.parse_args() 50 | 51 | 52 | def main(): 53 | """Run aiobfd""" 54 | args = parse_arguments() 55 | handlers = [] 56 | 57 | if (args.log_to_file or args.log_to_syslog) and not args.no_log_to_stdout: 58 | handlers.append(logging.StreamHandler(sys.stdout)) 59 | if args.log_to_file: 60 | handlers.append(logging.handlers.WatchedFileHandler(args.log_file)) 61 | if args.log_to_syslog: 62 | handlers.append(logging.handlers.SysLogHandler(args.log_sock)) 63 | 64 | log_format = '%(asctime)s %(name)-12s %(levelname)-8s %(message)s' 65 | logging.basicConfig(handlers=handlers, format=log_format, 66 | level=logging.getLevelName(args.log_level)) 67 | control = aiobfd.Control(args.local, [args.remote], family=args.family, 68 | passive=args.passive, 69 | rx_interval=args.rx_interval*1000, 70 | tx_interval=args.tx_interval*1000, 71 | detect_mult=args.detect_mult) 72 | control.run() 73 | 74 | if __name__ == '__main__': 75 | main() 76 | -------------------------------------------------------------------------------- /aiobfd/control.py: -------------------------------------------------------------------------------- 1 | """aiobfd: BFD Control process""" 2 | # pylint: disable=I0011,R0913 3 | 4 | import asyncio 5 | import logging 6 | import socket 7 | from .transport import Server 8 | from .session import Session 9 | from .packet import Packet 10 | log = logging.getLogger(__name__) # pylint: disable=I0011,C0103 11 | 12 | CONTROL_PORT = 3784 13 | 14 | 15 | class Control: 16 | """BFD Control""" 17 | 18 | def __init__(self, local, remotes, family=socket.AF_UNSPEC, passive=False, 19 | tx_interval=1000000, rx_interval=1000000, detect_mult=3, 20 | loop=asyncio.get_event_loop()): 21 | self.loop = loop 22 | self.rx_queue = asyncio.Queue() 23 | 24 | # Initialize client sessions 25 | self.sessions = list() 26 | for remote in remotes: 27 | log.debug('Creating BFD session for remote %s.', remote) 28 | self.sessions.append( 29 | Session(local, remote, family=family, passive=passive, 30 | tx_interval=tx_interval, rx_interval=rx_interval, 31 | detect_mult=detect_mult)) 32 | 33 | # Initialize server 34 | log.debug('Setting up UDP server on %s:%s.', local, CONTROL_PORT) 35 | task = self.loop.create_datagram_endpoint( 36 | lambda: Server(self.rx_queue), 37 | local_addr=(local, CONTROL_PORT), 38 | family=family) 39 | self.server, _ = self.loop.run_until_complete(task) 40 | log.info('Accepting traffic on %s:%s.', 41 | self.server.get_extra_info('sockname')[0], 42 | self.server.get_extra_info('sockname')[1]) 43 | 44 | async def rx_packets(self): 45 | """Process a received BFD Control packets""" 46 | log.debug('Control process ready to receive packets.') 47 | while True: 48 | packet, source = await self.rx_queue.get() 49 | log.debug('Received a new packet from %s.', source) 50 | self.process_packet(packet, source) 51 | self.rx_queue.task_done() 52 | 53 | def process_packet(self, data, source): 54 | """Process a received packet""" 55 | try: 56 | packet = Packet(data, source) 57 | except IOError as exc: 58 | log.info('Dropping packet: %s', exc) 59 | return 60 | 61 | # If the Your Discriminator field is nonzero, it MUST be used to select 62 | # the session with which this BFD packet is associated. If no session 63 | # is found, the packet MUST be discarded. 64 | if packet.your_discr: 65 | for session in self.sessions: 66 | if session.local_discr == packet.your_discr: 67 | session.rx_packet(packet) 68 | return 69 | else: 70 | # If the Your Discriminator field is zero, the session MUST be 71 | # selected based on some combination of other fields ... 72 | for session in self.sessions: 73 | if session.remote == packet.source: 74 | session.rx_packet(packet) 75 | return 76 | 77 | # If a matching session is not found, a new session MAY be created, 78 | # or the packet MAY be discarded. Note: We discard for now. 79 | log.info('Dropping packet from %s as it doesn\'t match any ' 80 | 'configured remote.', packet.source) 81 | 82 | def run(self): 83 | """Main function""" 84 | 85 | asyncio.ensure_future(self.rx_packets()) 86 | 87 | try: 88 | log.warning('BFD Daemon fully configured.') 89 | self.loop.run_forever() 90 | except KeyboardInterrupt: 91 | def shutdown_exception_handler(loop, context): # pragma: no cover 92 | """Do not show `asyncio.CancelledError` exceptions""" 93 | if "exception" not in context or not \ 94 | isinstance(context["exception"], asyncio.CancelledError): 95 | loop.default_exception_handler(context) 96 | self.loop.set_exception_handler(shutdown_exception_handler) 97 | log.info('Keyboard interrupt detected.') 98 | 99 | # Wait for all tasks to be cancelled 100 | tasks = asyncio.gather(*asyncio.Task.all_tasks(loop=self.loop), 101 | loop=self.loop, return_exceptions=True) 102 | tasks.add_done_callback(lambda t: self.loop.stop()) 103 | tasks.cancel() 104 | 105 | # Keep the event loop running until it is either destroyed or all 106 | # tasks have really terminated 107 | while not tasks.done() and not self.loop.is_closed(): 108 | self.loop.run_forever() # pragma: no cover 109 | finally: 110 | self.loop.close() 111 | -------------------------------------------------------------------------------- /aiobfd/packet.py: -------------------------------------------------------------------------------- 1 | """aiobfd: BFD Control Packet""" 2 | # pylint: disable=I0011,E0632,R0902 3 | 4 | import logging 5 | import bitstring 6 | log = logging.getLogger(__name__) # pylint: disable=I0011,C0103 7 | 8 | MIN_PACKET_SIZE = 24 9 | MIN_AUTH_PACKET_SIZE = 26 10 | STATE_ADMIN_DOWN = 0 # AdminDown 11 | STATE_DOWN = 1 # Down 12 | 13 | ''' 14 | 0 1 2 3 15 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 16 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 17 | |Vers | Diag |Sta|P|F|C|A|D|M| Detect Mult | Length | 18 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 19 | | My Discriminator | 20 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 21 | | Your Discriminator | 22 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 23 | | Desired Min TX Interval | 24 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 25 | | Required Min RX Interval | 26 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 27 | | Required Min Echo RX Interval | 28 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 29 | ''' 30 | 31 | PACKET_FORMAT = ( 32 | 'uint:3=version,' 33 | 'uint:5=diag,' 34 | 'uint:2=state,' 35 | 'bool=poll,' 36 | 'bool=final,' 37 | 'bool=control_plane_independent,' 38 | 'bool=authentication_present,' 39 | 'bool=demand_mode,' 40 | 'bool=multipoint,' 41 | 'uint:8=detect_mult,' 42 | 'uint:8=length,' 43 | 'uint:32=my_discr,' 44 | 'uint:32=your_discr,' 45 | 'uint:32=desired_min_tx_interval,' 46 | 'uint:32=required_min_rx_interval,' 47 | 'uint:32=required_min_echo_rx_interval' 48 | ) 49 | 50 | PACKET_DEBUG_MSG = '\n|--------------------------------------------------\n' \ 51 | '| Vers: %d Diag: %d State: %d Poll: %d Final: %d\n' \ 52 | '| CPI: %d Auth: %d Demand: %d Multi: %d DetectMult: %d\n' \ 53 | '| Length: %d MyDisc: %d YourDisc: %d\n' \ 54 | '| TxInterval: %d RxInterval: %d EchoRxInterval: %d\n' \ 55 | '|--------------------------------------------------' 56 | 57 | 58 | class Packet: # pylint: disable=I0011,R0903 59 | """A BFD Control Packet""" 60 | 61 | def __init__(self, data, source): 62 | self.source = source 63 | 64 | packet = bitstring.BitString(data) 65 | packet_length = packet.len / 8 66 | # Ensure packet is sufficiently long to attempt unpacking it 67 | 68 | if packet_length < MIN_PACKET_SIZE: 69 | raise IOError('Packet size below mininum correct value.') 70 | 71 | self.version, self.diag, self.state, self.poll, self.final, \ 72 | self.control_plane_independent, self.authentication_present,\ 73 | self.demand_mode, self.multipoint, self.detect_mult, self.length, \ 74 | self.my_discr, self.your_discr, self.desired_min_tx_interval, \ 75 | self.required_min_rx_interval, self.required_min_echo_rx_interval \ 76 | = packet.unpack(PACKET_FORMAT) 77 | 78 | log.debug(PACKET_DEBUG_MSG, self.version, self.diag, self.state, 79 | self.poll, self.final, self.control_plane_independent, 80 | self.authentication_present, self.demand_mode, 81 | self.multipoint, self.detect_mult, self.length, 82 | self.my_discr, self.your_discr, self.desired_min_tx_interval, 83 | self.required_min_rx_interval, 84 | self.required_min_echo_rx_interval) 85 | 86 | self.validate(packet_length) 87 | 88 | def validate(self, packet_length): 89 | """Validate received packet contents""" 90 | 91 | # If the version number is not correct (1), the packet MUST be 92 | # discarded. 93 | if self.version != 1: 94 | raise IOError('Unsupported BFD protcol version.') 95 | 96 | # If the Length field is less than the minimum correct value (24 if 97 | # the A bit is clear, or 26 if the A bit is set), the packet MUST be 98 | # discarded. 99 | if self.authentication_present and self.length < MIN_AUTH_PACKET_SIZE: 100 | raise IOError('Packet size below mininum correct value.') 101 | elif ((not self.authentication_present) 102 | and self.length < MIN_PACKET_SIZE): 103 | raise IOError('Packet size below mininum correct value.') 104 | 105 | # If the Length field is greater than the payload of the encapsulating 106 | # protocol, the packet MUST be discarded. 107 | if self.length > packet_length: 108 | raise IOError('Packet length field larger than received data.') 109 | 110 | # If the Multipoint (M) bit is nonzero, the packet MUST be discarded. 111 | if self.multipoint: 112 | raise IOError('Multipoint bit should be 0.') 113 | 114 | # If the My Discriminator field is zero, the packet MUST be discarded. 115 | if not self.my_discr: 116 | raise IOError('Discriminator field is zero.') 117 | 118 | # If the Your Discriminator field is zero and the State field is not 119 | # Down or AdminDown, the packet MUST be discarded. 120 | if self.state not in [STATE_DOWN, STATE_ADMIN_DOWN] \ 121 | and (not self.your_discr): 122 | raise IOError('Your Discriminator can\'t be zero in this state.') 123 | -------------------------------------------------------------------------------- /aiobfd/session.py: -------------------------------------------------------------------------------- 1 | """aiobfd: BFD Session with an individual remote""" 2 | # pylint: disable=I0011,R0902,R0913 3 | # pylint: disable=I0011,E1101 4 | # socket.IPPROTO_IPV6 missing on Windows 5 | 6 | import asyncio 7 | import random 8 | import socket 9 | import time 10 | import logging 11 | import bitstring 12 | from .transport import Client 13 | from .packet import PACKET_FORMAT, PACKET_DEBUG_MSG 14 | log = logging.getLogger(__name__) # pylint: disable=I0011,C0103 15 | 16 | SOURCE_PORT_MIN = 49152 17 | SOURCE_PORT_MAX = 65535 18 | CONTROL_PORT = 3784 19 | 20 | VERSION = 1 21 | 22 | DIAG_NONE = 0 # No Diagnostic 23 | DIAG_CONTROL_DETECTION_EXPIRED = 1 # Control Detection Time Expired 24 | DIAG_ECHO_FAILED = 2 # Echo Function Failed 25 | DIAG_NEIGHBOR_SIGNAL_DOWN = 3 # Neighbor Signaled Session Down 26 | DIAG_FORWARD_PLANE_RESET = 4 # Forwarding Plane Reset 27 | DIAG_PATH_DOWN = 5 # Path Down 28 | DIAG_CONCAT_PATH_DOWN = 6 # Concatenated Path Down 29 | DIAG_ADMIN_DOWN = 7 # Administratively Down 30 | DIAG_REV_CONCAT_PATH_DOWN = 8 # Reverse Concatenated Path Down 31 | 32 | STATE_ADMIN_DOWN = 0 # AdminDown 33 | STATE_DOWN = 1 # Down 34 | STATE_INIT = 2 # Init 35 | STATE_UP = 3 # Up 36 | 37 | CONTROL_PLANE_INDEPENDENT = False # Control Plane Independent 38 | 39 | # Default timers 40 | DESIRED_MIN_TX_INTERVAL = 1000000 # Minimum initial value 41 | 42 | # Keep these fields statically disabled as they're not implemented 43 | AUTH_TYPE = None # Authentication disabled 44 | DEMAND_MODE = False # Demand Mode 45 | MULTIPOINT = False # Multipoint 46 | REQUIRED_MIN_ECHO_RX_INTERVAL = 0 # Do not support echo packet 47 | 48 | 49 | class Session: 50 | """BFD session with a remote""" 51 | 52 | def __init__(self, local, remote, family=socket.AF_UNSPEC, passive=False, 53 | tx_interval=1000000, rx_interval=1000000, detect_mult=3): 54 | # Argument variables 55 | self.local = local 56 | self.remote = remote 57 | self.family = family 58 | self.passive = passive 59 | self.loop = asyncio.get_event_loop() 60 | self.rx_interval = rx_interval # User selectable value 61 | self.tx_interval = tx_interval # User selectable value 62 | 63 | # As per 6.8.1. State Variables 64 | self.state = STATE_DOWN 65 | self.remote_state = STATE_DOWN 66 | self.local_discr = random.randint(0, 4294967295) # 32-bit value 67 | self.remote_discr = 0 68 | self.local_diag = DIAG_NONE 69 | self._desired_min_tx_interval = DESIRED_MIN_TX_INTERVAL 70 | self._required_min_rx_interval = rx_interval 71 | self._remote_min_rx_interval = 1 72 | self.demand_mode = DEMAND_MODE 73 | self.remote_demand_mode = False 74 | self.detect_mult = detect_mult 75 | self.auth_type = AUTH_TYPE 76 | self.rcv_auth_seq = 0 77 | self.xmit_auth_seq = random.randint(0, 4294967295) # 32-bit value 78 | self.auth_seq_known = False 79 | 80 | # State Variables beyond those defined in RFC 5880 81 | self._async_tx_interval = 1000000 82 | self._final_async_tx_interval = None # Used to delay timer changes 83 | self.last_rx_packet_time = None 84 | self._async_detect_time = None 85 | self._final_async_detect_time = None # Used to delay timer changes 86 | self.poll_sequence = False 87 | self._remote_detect_mult = None 88 | self._remote_min_tx_interval = None 89 | self._tx_packets = None 90 | 91 | # Create the local client and run it once to grab a port 92 | log.debug('Setting up UDP client for %s:%s.', remote, CONTROL_PORT) 93 | src_port = random.randint(SOURCE_PORT_MIN, SOURCE_PORT_MAX) 94 | fam, _, _, _, addr = socket.getaddrinfo(self.local, src_port)[0] 95 | sock = socket.socket(family=fam, type=socket.SOCK_DGRAM) 96 | if fam == socket.AF_INET: 97 | sock.setsockopt(socket.SOL_IP, socket.IP_TTL, 255) 98 | elif fam == socket.AF_INET6: 99 | # Under Windows the IPv6 socket constant is somehow missing 100 | # https://bugs.python.org/issue29515 101 | sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_UNICAST_HOPS, 255) 102 | sock.bind(addr) 103 | task = self.loop.create_datagram_endpoint(Client, sock=sock) 104 | self.client, _ = self.loop.run_until_complete(task) 105 | log.info('Sourcing traffic for %s:%s from %s:%s.', 106 | remote, CONTROL_PORT, 107 | self.client.get_extra_info('sockname')[0], 108 | self.client.get_extra_info('sockname')[1]) 109 | 110 | # Schedule the coroutines to transmit packets and detect failures 111 | self._tx_packets = asyncio.ensure_future(self.async_tx_packets()) 112 | asyncio.ensure_future(self.detect_async_failure()) 113 | 114 | # The transmit interval MUST be recalculated whenever 115 | # bfd.DesiredMinTxInterval changes, or whenever bfd.RemoteMinRxInterval 116 | # changes, and is equal to the greater of those two values. 117 | # If either bfd.DesiredMinTxInterval is changed or 118 | # bfd.RequiredMinRxInterval is changed, a Poll Sequence MUST be 119 | # initiated (see section 6.5) 120 | @property 121 | def desired_min_tx_interval(self): 122 | """bfd.DesiredMinTxInterval""" 123 | return self._desired_min_tx_interval 124 | 125 | @desired_min_tx_interval.setter 126 | def desired_min_tx_interval(self, value): 127 | if value == self._desired_min_tx_interval: 128 | return 129 | log.info('bfd.DesiredMinTxInterval changed from %d to %d, starting ' 130 | 'Poll Sequence.', self._desired_min_tx_interval, value) 131 | 132 | # If bfd.DesiredMinTxInterval is increased and bfd.SessionState is Up, 133 | # the actual transmission interval used MUST NOT change until the Poll 134 | # Sequence described above has terminated. 135 | tx_interval = max(value, self.remote_min_rx_interval) 136 | if value > self._desired_min_tx_interval and self.state == STATE_UP: 137 | self._final_async_tx_interval = tx_interval 138 | log.info('Delaying increase in Tx Interval from %d to %d ...', 139 | self._async_tx_interval, self._final_async_tx_interval) 140 | else: 141 | self._async_tx_interval = tx_interval 142 | self._desired_min_tx_interval = value 143 | self.poll_sequence = True 144 | 145 | @property 146 | def required_min_rx_interval(self): 147 | """bfd.RequiredMinRxInterval""" 148 | return self._required_min_rx_interval 149 | 150 | @required_min_rx_interval.setter 151 | def required_min_rx_interval(self, value): 152 | if value == self._required_min_rx_interval: 153 | return 154 | log.info('bfd.RequiredMinRxInterval changed from %d to %d, starting ' 155 | 'Poll Sequence.', self._required_min_rx_interval, value) 156 | 157 | detect_time = self.calc_detect_time(self.remote_detect_mult, 158 | value, 159 | self.remote_min_tx_interval) 160 | if value < self._required_min_rx_interval and self.state == STATE_UP: 161 | self._final_async_detect_time = detect_time 162 | log.info('Delaying decrease in Detect Time from %d to %d ...', 163 | self._async_detect_time, self._final_async_detect_time) 164 | else: 165 | self._async_detect_time = detect_time 166 | self._required_min_rx_interval = value 167 | self.poll_sequence = True 168 | 169 | @property 170 | def remote_min_rx_interval(self): 171 | """Property for remote_min_rx_interval so we can re-calculate 172 | the async_tx_interval whenever this value changes""" 173 | return self._remote_min_rx_interval 174 | 175 | @remote_min_rx_interval.setter 176 | def remote_min_rx_interval(self, value): 177 | if value == self._remote_min_rx_interval: 178 | return 179 | # If the local system reduces its transmit interval due to 180 | # bfd.RemoteMinRxInterval being reduced (the remote system has 181 | # advertised a reduced value in Required Min RX Interval), and the 182 | # remote system is not in Demand mode, the local system MUST honor 183 | # the new interval immediately. 184 | # We should cancel the tx_packets coro to do this. 185 | old_tx_interval = self._async_tx_interval 186 | self._async_tx_interval = max(value, self.desired_min_tx_interval) 187 | if self._async_tx_interval < old_tx_interval: 188 | log.info('Remote triggered decrease in the Tx Interval, forcing ' 189 | 'change by restarting the Tx Packets process.') 190 | self._restart_tx_packets() 191 | self._remote_min_rx_interval = value 192 | 193 | @property 194 | def remote_min_tx_interval(self): 195 | """Property for remote_min_tx_interval so we can re-calculate 196 | the detect_time whenever this value changes""" 197 | return self._remote_min_tx_interval 198 | 199 | @remote_min_tx_interval.setter 200 | def remote_min_tx_interval(self, value): 201 | if value == self._remote_min_tx_interval: 202 | return 203 | self._async_detect_time = \ 204 | self.calc_detect_time(self.remote_detect_mult, 205 | self.required_min_rx_interval, value) 206 | self._remote_min_tx_interval = value 207 | 208 | @property 209 | def remote_detect_mult(self): 210 | """Property for remote_detect_mult so we can re-calculate 211 | the detect_time whenever this value changes""" 212 | return self._remote_detect_mult 213 | 214 | @remote_detect_mult.setter 215 | def remote_detect_mult(self, value): 216 | if value == self._remote_detect_mult: 217 | return 218 | self._async_detect_time = \ 219 | self.calc_detect_time(value, self.required_min_rx_interval, 220 | self.remote_min_tx_interval) 221 | self._remote_detect_mult = value 222 | 223 | @staticmethod 224 | def calc_detect_time(detect_mult, rx_interval, tx_interval): 225 | """Calculate the BFD Detection Time""" 226 | 227 | # In Asynchronous mode, the Detection Time calculated in the local 228 | # system is equal to the value of Detect Mult received from the remote 229 | # system, multiplied by the agreed transmit interval of the remote 230 | # system (the greater of bfd.RequiredMinRxInterval and the last 231 | # received Desired Min TX Interval). 232 | if not (detect_mult and rx_interval and tx_interval): 233 | log.debug('BFD Detection Time calculation not possible with ' 234 | 'values detect_mult: %s rx_interval: %s tx_interval: %s', 235 | detect_mult, rx_interval, tx_interval) 236 | return None 237 | log.debug('BFD Detection Time calculated using ' 238 | 'detect_mult: %s rx_interval: %s tx_interval: %s', 239 | detect_mult, rx_interval, tx_interval) 240 | return detect_mult * max(rx_interval, tx_interval) 241 | 242 | def encode_packet(self, final=False): 243 | """Encode a single BFD Control packet""" 244 | 245 | # A system MUST NOT set the Demand (D) bit unless bfd.DemandMode is 1, 246 | # bfd.SessionState is Up, and bfd.RemoteSessionState is Up. 247 | demand = (self.demand_mode and self.state == STATE_UP and 248 | self.remote_state == STATE_UP) 249 | 250 | # A BFD Control packet MUST NOT have both the Poll (P) and Final (F) 251 | # bits set. We'll give the F bit priority, the P bit will still be set 252 | # in the next outgoing packet if needed. 253 | poll = self.poll_sequence if not final else False 254 | 255 | data = { 256 | 'version': VERSION, 257 | 'diag': self.local_diag, 258 | 'state': self.state, 259 | 'poll': poll, 260 | 'final': final, 261 | 'control_plane_independent': CONTROL_PLANE_INDEPENDENT, 262 | 'authentication_present': bool(self.auth_type), 263 | 'demand_mode': demand, 264 | 'multipoint': MULTIPOINT, 265 | 'detect_mult': self.detect_mult, 266 | 'length': 24, 267 | 'my_discr': self.local_discr, 268 | 'your_discr': self.remote_discr, 269 | 'desired_min_tx_interval': self.desired_min_tx_interval, 270 | 'required_min_rx_interval': self.required_min_rx_interval, 271 | 'required_min_echo_rx_interval': REQUIRED_MIN_ECHO_RX_INTERVAL 272 | } 273 | 274 | log.debug(PACKET_DEBUG_MSG, VERSION, self.local_diag, self.state, 275 | poll, final, CONTROL_PLANE_INDEPENDENT, bool(self.auth_type), 276 | demand, MULTIPOINT, self.detect_mult, 24, self.local_discr, 277 | self.remote_discr, self.desired_min_tx_interval, 278 | self.required_min_rx_interval, REQUIRED_MIN_ECHO_RX_INTERVAL) 279 | 280 | return bitstring.pack(PACKET_FORMAT, **data).bytes 281 | 282 | def tx_packet(self, final=False): 283 | """Transmit a single BFD packet to the remote peer""" 284 | log.debug('Transmitting BFD packet to %s:%s', 285 | self.remote, CONTROL_PORT) 286 | self.client.sendto( 287 | self.encode_packet(final), (self.remote, CONTROL_PORT)) 288 | 289 | async def async_tx_packets(self): 290 | """Asynchronously transmit control packet""" 291 | try: 292 | while True: 293 | # A system MUST NOT transmit BFD Control packets if 294 | # bfd.RemoteDiscr is zero and the system is taking the Passive 295 | # role. A system MUST NOT periodically transmit BFD Control 296 | # packets if bfd.RemoteMinRxInterval is zero. 297 | # A system MUST NOT periodically transmit BFD Control packets 298 | # if Demand mode is active on the remote system 299 | # (bfd.RemoteDemandMode) is 1, bfd.SessionState is Up, and 300 | # bfd.RemoteSessionState is Up) and a Poll Sequence is not 301 | # being transmitted. 302 | if not((self.remote_discr == 0 and self.passive) or 303 | (self.remote_min_rx_interval == 0) or 304 | (not self.poll_sequence and 305 | (self.remote_demand_mode == 1 and 306 | self.state == STATE_UP and 307 | self.remote_state == STATE_UP))): 308 | self.tx_packet() 309 | 310 | # The periodic transmission of BFD Control packets MUST be 311 | # jittered on a per-packet basis by up to 25% 312 | # If bfd.DetectMult is equal to 1, the interval between 313 | # transmitted BFD Control packets MUST be no more than 90% of 314 | # the negotiated transmission interval, and MUST be no less 315 | # than 75% of the negotiated transmission interval. 316 | if self.detect_mult == 1: 317 | interval = \ 318 | self._async_tx_interval * random.uniform(0.75, 0.90) 319 | else: 320 | interval = \ 321 | self._async_tx_interval * (1 - random.uniform(0, 0.25)) 322 | await asyncio.sleep(interval/1000000) 323 | except asyncio.CancelledError: # pragma: no cover 324 | log.info('tx_packets() was cancelled ...') 325 | 326 | def _restart_tx_packets(self): 327 | """Allow other co-routines to request a restart of tx_packets() 328 | when needed, i.e. due to a timer change""" 329 | 330 | log.info('Attempting to cancel tx_packets() ...') 331 | self._tx_packets.cancel() 332 | log.info('Restarting tx_packets() ...') 333 | self._tx_packets = asyncio.ensure_future(self.async_tx_packets()) 334 | 335 | def rx_packet(self, packet): # pylint: disable=I0011,R0912,R0915 336 | """Receive packet""" 337 | 338 | # If the A bit is set and no authentication is in use (bfd.AuthType 339 | # is zero), the packet MUST be discarded. 340 | if packet.authentication_present and not self.auth_type: 341 | raise IOError('Received packet with authentication while no ' 342 | 'authentication is configured locally.') 343 | 344 | # If the A bit is clear and authentication is in use (bfd.AuthType 345 | # is nonzero), the packet MUST be discarded. 346 | if (not packet.authentication_present) and self.auth_type: 347 | raise IOError('Received packet without authentication while ' 348 | 'authentication is configured locally.') 349 | 350 | # If the A bit is set authenticate the packet under the rules of 351 | # section 6.7. 352 | if packet.authentication_present: 353 | log.critical('Authenticated packet received, not supported!') 354 | return 355 | 356 | # Set bfd.RemoteDiscr to the value of My Discriminator. 357 | self.remote_discr = packet.my_discr 358 | 359 | # Set bfd.RemoteState to the value of the State (Sta) field. 360 | self.remote_state = packet.state 361 | 362 | # Set bfd.RemoteDemandMode to the value of the Demand (D) bit. 363 | self.remote_demand_mode = packet.demand_mode 364 | 365 | # Set bfd.RemoteMinRxInterval to the value of Required Min RX Interval. 366 | self.remote_min_rx_interval = packet.required_min_rx_interval 367 | 368 | # Non-RFC defined session state that we track anyway 369 | self.remote_detect_mult = packet.detect_mult 370 | self.remote_min_tx_interval = packet.desired_min_tx_interval 371 | 372 | # Implementation of the FSM in section 6.8.6 373 | if self.state == STATE_ADMIN_DOWN: 374 | log.warning('Received packet from %s while in Admin Down state.', 375 | self.remote) 376 | return 377 | if packet.state == STATE_ADMIN_DOWN: 378 | if self.state != STATE_DOWN: 379 | self.local_diag = DIAG_NEIGHBOR_SIGNAL_DOWN 380 | self.state = STATE_DOWN 381 | self.desired_min_tx_interval = DESIRED_MIN_TX_INTERVAL 382 | log.error('BFD remote %s signaled going ADMIN_DOWN.', 383 | self.remote) 384 | else: 385 | if self.state == STATE_DOWN: 386 | if packet.state == STATE_DOWN: 387 | self.state = STATE_INIT 388 | log.error('BFD session with %s going to INIT state.', 389 | self.remote) 390 | elif packet.state == STATE_INIT: 391 | self.state = STATE_UP 392 | self.desired_min_tx_interval = self.tx_interval 393 | log.error('BFD session with %s going to UP state.', 394 | self.remote) 395 | elif self.state == STATE_INIT: 396 | if packet.state in (STATE_INIT, STATE_UP): 397 | self.state = STATE_UP 398 | self.desired_min_tx_interval = self.tx_interval 399 | log.error('BFD session with %s going to UP state.', 400 | self.remote) 401 | else: 402 | if packet.state == STATE_DOWN: 403 | self.local_diag = DIAG_NEIGHBOR_SIGNAL_DOWN 404 | self.state = STATE_DOWN 405 | log.error('BFD remote %s signaled going DOWN.', 406 | self.remote) 407 | 408 | # If a BFD Control packet is received with the Poll (P) bit set to 1, 409 | # the receiving system MUST transmit a BFD Control packet with the Poll 410 | # (P) bit clear and the Final (F) bit set as soon as practicable, ... 411 | if packet.poll: 412 | log.info('Received packet with Poll (P) bit set from %s, ' 413 | 'sending packet with Final (F) bit set.', self.remote) 414 | self.tx_packet(final=True) 415 | 416 | # When the system sending the Poll sequence receives a packet with 417 | # Final, the Poll Sequence is terminated 418 | if packet.final: 419 | log.info('Received packet with Final (F) bit set from %s, ' 420 | 'ending Poll Sequence.', self.remote) 421 | self.poll_sequence = False 422 | if self._final_async_tx_interval: 423 | log.info('Increasing Tx Interval from %d to %d now that Poll ' 424 | 'Sequence has ended.', self._async_tx_interval, 425 | self._final_async_tx_interval) 426 | self._async_tx_interval = self._final_async_tx_interval 427 | self._final_async_tx_interval = None 428 | if self._final_async_detect_time: 429 | log.info('Increasing Detect Time from %d to %d now that Poll ' 430 | 'Sequence has ended.', self._async_detect_time, 431 | self._final_async_detect_time) 432 | self._async_detect_time = self._final_async_detect_time 433 | self._final_async_detect_time = None 434 | 435 | # Set the time a packet was received to right now 436 | self.last_rx_packet_time = time.time() 437 | log.debug('Valid packet received from %s, updating last packet time.', 438 | self.remote) 439 | 440 | async def detect_async_failure(self): 441 | """Detect if a session has failed in asynchronous mode""" 442 | while True: 443 | if not (self.demand_mode or self._async_detect_time is None): 444 | # If Demand mode is not active, and a period of time equal to 445 | # the Detection Time passes without receiving a BFD Control 446 | # packet from the remote system, and bfd.SessionState is Init 447 | # or Up, the session has gone down -- the local system MUST set 448 | # bfd.SessionState to Down and bfd.LocalDiag to 1. 449 | if self.state in (STATE_INIT, STATE_UP) and \ 450 | ((time.time() - self.last_rx_packet_time) > 451 | (self._async_detect_time/1000000)): 452 | self.state = STATE_DOWN 453 | self.local_diag = DIAG_CONTROL_DETECTION_EXPIRED 454 | self.desired_min_tx_interval = DESIRED_MIN_TX_INTERVAL 455 | log.critical('Detected BFD remote %s going DOWN!', 456 | self.remote) 457 | log.info('Time since last packet: %d ms; ' 458 | 'Detect Time: %d ms', 459 | (time.time() - self.last_rx_packet_time) * 1000, 460 | self._async_detect_time/1000) 461 | await asyncio.sleep(1/1000) 462 | -------------------------------------------------------------------------------- /aiobfd/transport.py: -------------------------------------------------------------------------------- 1 | """aiobfd: BFD IPv4/IPv6 transport""" 2 | 3 | import asyncio 4 | import logging 5 | log = logging.getLogger(__name__) # pylint: disable=I0011,C0103 6 | 7 | 8 | class Client: 9 | """BFD Client for sourcing egress datagrams""" 10 | 11 | def __init__(self): 12 | self.transport = None 13 | 14 | def connection_made(self, transport): 15 | """Socket setup correctly""" 16 | self.transport = transport 17 | 18 | @staticmethod 19 | def datagram_received(_, addr): 20 | """Received a packet""" 21 | log.info(('Unexpectedly received a packet on a BFD source port ' 22 | 'from %s on port %d'), addr[0], addr[1]) 23 | 24 | @staticmethod 25 | def error_received(exc): 26 | """Error occurred""" 27 | log.error('Socket error received: %s', exc) 28 | 29 | 30 | class Server: 31 | """BFD Server for receiving ingress datagrams """ 32 | 33 | def __init__(self, rx_queue): 34 | self.transport = None 35 | self.rx_queue = rx_queue 36 | 37 | def connection_made(self, transport): 38 | """Socket setup correctly""" 39 | self.transport = transport 40 | 41 | def datagram_received(self, data, addr): 42 | """Received a packet""" 43 | asyncio.ensure_future(self.rx_queue.put((data, addr[0]))) 44 | 45 | @staticmethod 46 | def error_received(exc): 47 | """Error occurred""" 48 | log.error('Socket error received: %s', exc) 49 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bitstring 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """aiobfd setup""" 4 | 5 | from distutils.core import setup 6 | from setuptools import find_packages 7 | 8 | setup(name='aiobfd', 9 | version='0.2', 10 | description='Asynchronous BFD Daemon', 11 | author='Kris Lambrechts', 12 | author_email='kris@netedge.plus', 13 | license='MIT', 14 | classifiers=[ 15 | 'Development Status :: 3 - Alpha', 16 | 'Framework :: AsyncIO', 17 | 'Intended Audience :: Telecommunications Industry', 18 | 'Topic :: System :: Networking :: Monitoring :: Hardware Watchdog', 19 | 'License :: OSI Approved :: MIT License', 20 | 'Programming Language :: Python :: 3 :: Only', 21 | 'Programming Language :: Python :: 3.5', 22 | 'Programming Language :: Python :: 3.6', 23 | ], 24 | keywords='BFD Bidirectional Forwarding Detection rfc5880', 25 | url='https://github.com/netedgeplus/aiobfd', 26 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 27 | install_requires=['bitstring'], 28 | tests_require=['pytest', 'pytest-asyncio', 'pytest-cov', 'pytest-mock', 29 | 'coverage'], 30 | python_requires='>=3.5, <4', 31 | entry_points={'console_scripts': ['aiobfd=aiobfd.__main__:main']}) 32 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netedgeplus/aiobfd/090122474d5bf6266393f0908c13be5812fdb1c3/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_control.py: -------------------------------------------------------------------------------- 1 | """Test aiobfd/control.py""" 2 | # pylint: disable=I0011,W0621,E1101,W0611 3 | 4 | import platform 5 | import socket 6 | from unittest.mock import MagicMock 7 | import pytest 8 | import bitstring 9 | import aiobfd.control 10 | from aiobfd.packet import PACKET_FORMAT 11 | from tests.test_packet import PACKET_FORMAT_TOO_SHORT 12 | from tests.test_packet import valid_data # noqa: F401 13 | 14 | 15 | class ErrorAfter(object): # pylint: disable=I0011,R0903 16 | """ Callable that will raise `CallableExhausted` 17 | exception after `limit` calls """ 18 | def __init__(self, limit): 19 | self.limit = limit 20 | self.calls = 0 21 | 22 | def __call__(self, *args, **kwargs): 23 | self.calls += 1 24 | if self.calls > self.limit: 25 | raise CallableExhausted 26 | 27 | 28 | class InterruptAfter(object): # pylint: disable=I0011,R0903 29 | """ Callable that will raise `KeyboardInterrupt` 30 | exception after `limit` calls """ 31 | def __init__(self, limit): 32 | self.limit = limit 33 | self.calls = 0 34 | 35 | def __call__(self, *args, **kwargs): 36 | self.calls += 1 37 | if self.calls > self.limit: 38 | raise KeyboardInterrupt 39 | 40 | 41 | class CallableExhausted(Exception): 42 | """Exception that gets raised after `limit` calls""" 43 | pass 44 | 45 | 46 | @pytest.fixture() 47 | def control(event_loop): 48 | """Create a basic aiobfd control session""" 49 | return aiobfd.control.Control('127.0.0.1', ['127.0.0.1'], loop=event_loop) 50 | 51 | 52 | def test_control_ipv4(mocker): 53 | """Create a basic IPv4 Control process""" 54 | mocker.patch('aiobfd.control.log') 55 | aiobfd.control.Control('127.0.0.1', ['127.0.0.1']) 56 | aiobfd.control.log.debug.assert_has_calls( 57 | [mocker.call('Creating BFD session for remote %s.', '127.0.0.1'), 58 | mocker.call('Setting up UDP server on %s:%s.', '127.0.0.1', 59 | aiobfd.control.CONTROL_PORT)]) 60 | aiobfd.control.log.info.assert_called_once_with( 61 | 'Accepting traffic on %s:%s.', '127.0.0.1', 62 | aiobfd.control.CONTROL_PORT) 63 | 64 | 65 | @pytest.mark.skipif(platform.node() == 'carbon', 66 | reason='IPv6 tests fail on Windows right now') 67 | def test_control_ipv6(mocker): 68 | """Create a basic IPv6 Control process""" 69 | mocker.patch('aiobfd.control.log') 70 | aiobfd.control.Control('::1', ['::1']) 71 | aiobfd.control.log.debug.assert_has_calls( 72 | [mocker.call('Creating BFD session for remote %s.', '::1'), 73 | mocker.call('Setting up UDP server on %s:%s.', '::1', 74 | aiobfd.control.CONTROL_PORT)]) 75 | aiobfd.control.log.info.assert_called_once_with( 76 | 'Accepting traffic on %s:%s.', '::1', 77 | aiobfd.control.CONTROL_PORT) 78 | 79 | 80 | def test_control_hostname(mocker): 81 | """Create a basic IPv4 Control process from hostname""" 82 | mocker.patch('aiobfd.control.log') 83 | aiobfd.control.Control('localhost', ['localhost']) 84 | aiobfd.control.log.debug.assert_has_calls( 85 | [mocker.call('Creating BFD session for remote %s.', 'localhost'), 86 | mocker.call('Setting up UDP server on %s:%s.', 'localhost', 87 | aiobfd.control.CONTROL_PORT)]) 88 | aiobfd.control.log.info.assert_called_once_with( 89 | 'Accepting traffic on %s:%s.', '127.0.0.1', 90 | aiobfd.control.CONTROL_PORT) 91 | 92 | 93 | def test_control_hostname_force_v4(mocker): 94 | """Create a forced IPv4 Control process from hostname""" 95 | mocker.patch('aiobfd.control.log') 96 | aiobfd.control.Control('localhost', ['localhost'], family=socket.AF_INET) 97 | aiobfd.control.log.debug.assert_has_calls( 98 | [mocker.call('Creating BFD session for remote %s.', 'localhost'), 99 | mocker.call('Setting up UDP server on %s:%s.', 'localhost', 100 | aiobfd.control.CONTROL_PORT)]) 101 | aiobfd.control.log.info.assert_called_once_with( 102 | 'Accepting traffic on %s:%s.', '127.0.0.1', 103 | aiobfd.control.CONTROL_PORT) 104 | 105 | 106 | @pytest.mark.skipif(platform.node() == 'carbon', 107 | reason='IPv6 tests fail on Windows right now') 108 | def test_control_hostname_force_v6(mocker): 109 | """Create a forced IPv6 Control process from hostname""" 110 | mocker.patch('aiobfd.control.log') 111 | aiobfd.control.Control('localhost', ['localhost'], family=socket.AF_INET6) 112 | aiobfd.control.log.debug.assert_has_calls( 113 | [mocker.call('Creating BFD session for remote %s.', 'localhost'), 114 | mocker.call('Setting up UDP server on %s:%s.', 'localhost', 115 | aiobfd.control.CONTROL_PORT)]) 116 | aiobfd.control.log.info.assert_called_once_with( 117 | 'Accepting traffic on %s:%s.', '::1', 118 | aiobfd.control.CONTROL_PORT) 119 | 120 | 121 | def test_process_invalid_packet(control, valid_data, mocker): # noqa: F811 122 | """Inject an invalid packet and monitor the log""" 123 | packet = bitstring.pack(PACKET_FORMAT_TOO_SHORT, **valid_data) 124 | mocker.patch('aiobfd.control.log') 125 | control.process_packet(packet, '172.0.0.1') 126 | aiobfd.control.log.info.assert_called_once_with( 127 | 'Dropping packet: %s', mocker.ANY) 128 | control.sessions[0]._tx_packets.cancel() # pylint: disable=I0011,W0212 129 | 130 | 131 | def test_process_pkt_unknown_remote(control, valid_data, mocker): # noqa: F811 132 | """Inject a valid packet from unconfigured remote and monitor the log""" 133 | packet = bitstring.pack(PACKET_FORMAT, **valid_data) 134 | mocker.patch('aiobfd.control.log') 135 | control.process_packet(packet, '127.0.0.2') 136 | aiobfd.control.log.info.assert_called_once_with( 137 | 'Dropping packet from %s as it doesn\'t match any configured remote.', 138 | '127.0.0.2') 139 | control.sessions[0]._tx_packets.cancel() # pylint: disable=I0011,W0212 140 | 141 | 142 | def test_valid_remote_your_discr_0(control, valid_data, mocker): # noqa: F811 143 | """Inject a valid packet and monitor the log""" 144 | packet = bitstring.pack(PACKET_FORMAT, **valid_data) 145 | mocker.patch('aiobfd.control.log') 146 | control.process_packet(packet, '127.0.0.1') 147 | aiobfd.control.log.info.assert_not_called() 148 | aiobfd.control.log.warning.assert_not_called() 149 | aiobfd.control.log.debug.assert_not_called() 150 | control.sessions[0]._tx_packets.cancel() # pylint: disable=I0011,W0212 151 | 152 | 153 | def test_valid_remote_your_discr_1(control, valid_data, mocker): # noqa: F811 154 | """Inject a valid packet and monitor the log""" 155 | valid_data['your_discr'] = control.sessions[0].local_discr 156 | packet = bitstring.pack(PACKET_FORMAT, **valid_data) 157 | mocker.patch('aiobfd.control.log') 158 | control.process_packet(packet, '127.0.0.1') 159 | aiobfd.control.log.info.assert_not_called() 160 | aiobfd.control.log.warning.assert_not_called() 161 | aiobfd.control.log.debug.assert_not_called() 162 | control.sessions[0]._tx_packets.cancel() # pylint: disable=I0011,W0212 163 | 164 | 165 | @pytest.mark.asyncio # noqa: F811 166 | async def test_rx_packets(control, valid_data): 167 | """Test the Rx Packets loop""" 168 | control.process_packet = MagicMock(side_effect=ErrorAfter(1)) 169 | await control.rx_queue.put((valid_data, '127.0.0.1')) 170 | await control.rx_queue.put((valid_data, '127.0.0.1')) 171 | with pytest.raises(CallableExhausted): 172 | await control.rx_packets() 173 | 174 | 175 | def test_run(control, mocker): # noqa: F811 176 | """Run the control loop""" 177 | mocker.patch('aiobfd.control.log') 178 | mocker.patch.object(control.loop, 'run_forever') 179 | control.loop.run_forever.side_effect = InterruptAfter(0) 180 | control.run() 181 | aiobfd.control.log.warning.assert_called_once_with( 182 | 'BFD Daemon fully configured.') 183 | aiobfd.control.log.info.assert_called_once_with( 184 | 'Keyboard interrupt detected.') 185 | control.sessions[0]._tx_packets.cancel() # pylint: disable=I0011,W0212 186 | -------------------------------------------------------------------------------- /tests/test_packet.py: -------------------------------------------------------------------------------- 1 | """Test aiobfd/packet.py""" 2 | # pylint: disable=I0011,W0621 3 | 4 | import pytest 5 | import bitstring 6 | from aiobfd.packet import Packet, PACKET_FORMAT 7 | 8 | PACKET_FORMAT_TOO_SHORT = ( 9 | 'uint:3=version,' 10 | 'uint:5=diag,' 11 | 'uint:2=state,' 12 | 'bool=poll,' 13 | 'bool=final,' 14 | 'bool=control_plane_independent,' 15 | 'bool=authentication_present,' 16 | 'bool=demand_mode,' 17 | 'bool=multipoint,' 18 | 'uint:8=detect_mult,' 19 | 'uint:8=length,' 20 | ) 21 | 22 | 23 | @pytest.fixture() 24 | def valid_data(): 25 | """Valid sample data""" 26 | data = { 27 | 'version': 1, 28 | 'diag': 0, 29 | 'state': 0, 30 | 'poll': 0, 31 | 'final': 0, 32 | 'control_plane_independent': 0, 33 | 'authentication_present': 0, 34 | 'demand_mode': 0, 35 | 'multipoint': 0, 36 | 'detect_mult': 1, 37 | 'length': 24, 38 | 'my_discr': 1, 39 | 'your_discr': 0, 40 | 'desired_min_tx_interval': 50000, 41 | 'required_min_rx_interval': 50000, 42 | 'required_min_echo_rx_interval': 0 43 | } 44 | return data 45 | 46 | 47 | def test_valid_packet(valid_data): 48 | """Test whether a valid packet raises no exceptions""" 49 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 50 | 51 | 52 | def test_packet_too_short(valid_data): 53 | """Test whether version 0 raises an exception""" 54 | with pytest.raises(IOError): 55 | Packet(bitstring.pack(PACKET_FORMAT_TOO_SHORT, **valid_data), 56 | '127.0.0.1') 57 | 58 | 59 | def test_protocol_version_0(valid_data): 60 | """Test whether version 0 raises an exception""" 61 | valid_data['version'] = 0 62 | with pytest.raises(IOError): 63 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 64 | 65 | 66 | def test_protocol_version_2(valid_data): 67 | """Test whether version 2 raises an exception""" 68 | valid_data['version'] = 2 69 | with pytest.raises(IOError): 70 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 71 | 72 | 73 | def test_length_23(valid_data): 74 | """Test whether too short length raises an exception""" 75 | valid_data['length'] = 23 76 | with pytest.raises(IOError): 77 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 78 | 79 | 80 | def test_length_0(valid_data): 81 | """Test whether no length set raises an exception""" 82 | valid_data['length'] = 0 83 | with pytest.raises(IOError): 84 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 85 | 86 | 87 | def test_length_25(valid_data): 88 | """Test whether length beyond packet size raises exception""" 89 | valid_data['length'] = 25 90 | with pytest.raises(IOError): 91 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 92 | 93 | 94 | def test_multipoint(valid_data): 95 | """Test whether setting the multipoint bit raises an exception""" 96 | valid_data['multipoint'] = 1 97 | with pytest.raises(IOError): 98 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 99 | 100 | 101 | def test_my_discr_0(valid_data): 102 | """Test whether leaving the my_discr empty raises an exception""" 103 | valid_data['my_discr'] = 0 104 | with pytest.raises(IOError): 105 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 106 | 107 | 108 | def test_your_discr_0_when_up(valid_data): 109 | """Test whether your_disc being 0 when in up state raises an exception """ 110 | valid_data['state'] = 3 111 | valid_data['your_discr'] = 0 112 | with pytest.raises(IOError): 113 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 114 | 115 | 116 | def test_your_discr_0_when_init(valid_data): 117 | """Test whether your_disc being 0 when in init state raises an 118 | exception """ 119 | valid_data['state'] = 2 120 | valid_data['your_discr'] = 0 121 | with pytest.raises(IOError): 122 | Packet(bitstring.pack(PACKET_FORMAT, **valid_data), '127.0.0.1') 123 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | """Test aiobfd/session.py""" 2 | # pylint: disable=I0011,W0621,E1101,W0611,W0212 3 | 4 | import asyncio 5 | import platform 6 | import socket 7 | import time 8 | from unittest.mock import MagicMock 9 | import bitstring 10 | import pytest 11 | import aiobfd.session 12 | from aiobfd.packet import Packet, PACKET_FORMAT 13 | 14 | 15 | class AsyncMock(MagicMock): 16 | """Make MagicMock Async""" 17 | async def __call__(self, *args, **kwargs): 18 | return super(AsyncMock, self).__call__(*args, **kwargs) 19 | 20 | 21 | class ErrorAfter(object): # pylint: disable=I0011,R0903 22 | """ Callable that will raise `CallableExhausted` 23 | exception after `limit` calls """ 24 | def __init__(self, limit): 25 | self.limit = limit 26 | self.calls = 0 27 | 28 | def __call__(self, *args, **kwargs): 29 | self.calls += 1 30 | if self.calls > self.limit: 31 | raise CallableExhausted 32 | 33 | 34 | class CallableExhausted(Exception): 35 | """Exception that gets raised after `limit` calls""" 36 | pass 37 | 38 | 39 | @pytest.fixture() 40 | def session(): 41 | """Create a basic aiobfd session""" 42 | return aiobfd.session.Session('127.0.0.1', '127.0.0.1') 43 | 44 | 45 | @pytest.fixture() 46 | def valid_packet(): 47 | """Valid sample packet""" 48 | data = { 49 | 'version': 1, 50 | 'diag': 0, 51 | 'state': 0, 52 | 'poll': 0, 53 | 'final': 0, 54 | 'control_plane_independent': 0, 55 | 'authentication_present': 0, 56 | 'demand_mode': 0, 57 | 'multipoint': 0, 58 | 'detect_mult': 1, 59 | 'length': 24, 60 | 'my_discr': 1, 61 | 'your_discr': 0, 62 | 'desired_min_tx_interval': 50000, 63 | 'required_min_rx_interval': 50000, 64 | 'required_min_echo_rx_interval': 0 65 | } 66 | return Packet(bitstring.pack(PACKET_FORMAT, **data), '127.0.0.1') 67 | 68 | 69 | def test_session_ipv4(mocker): 70 | """Create a basic IPv4 Session process""" 71 | mocker.patch('aiobfd.session.log') 72 | mocker.patch.object(aiobfd.session.Session, 'async_tx_packets', 73 | new_callable=AsyncMock) 74 | session = aiobfd.session.Session('127.0.0.1', '127.0.0.1') 75 | aiobfd.session.log.debug.assert_called_once_with( 76 | 'Setting up UDP client for %s:%s.', '127.0.0.1', 77 | aiobfd.session.CONTROL_PORT) 78 | aiobfd.session.log.info.assert_called_once_with( 79 | 'Sourcing traffic for %s:%s from %s:%s.', '127.0.0.1', 80 | aiobfd.session.CONTROL_PORT, '127.0.0.1', mocker.ANY) 81 | try: 82 | session._tx_packets.cancel() # pylint: disable=I0011,W0212 83 | except asyncio.CancelledError: 84 | pass 85 | 86 | 87 | @pytest.mark.skipif(platform.node() == 'carbon', 88 | reason='IPv6 tests fail on Windows right now') 89 | def test_session_ipv6(mocker): 90 | """Create a basic IPv6 Session process""" 91 | mocker.patch('aiobfd.session.log') 92 | mocker.patch.object(aiobfd.session.Session, 'async_tx_packets', 93 | new_callable=AsyncMock) 94 | session = aiobfd.session.Session('::1', '::1') 95 | aiobfd.session.log.debug.assert_called_once_with( 96 | 'Setting up UDP client for %s:%s.', '::1', 97 | aiobfd.session.CONTROL_PORT) 98 | aiobfd.session.log.info.assert_called_once_with( 99 | 'Sourcing traffic for %s:%s from %s:%s.', '::1', 100 | aiobfd.session.CONTROL_PORT, '::1', mocker.ANY) 101 | try: 102 | session._tx_packets.cancel() # pylint: disable=I0011,W0212 103 | except asyncio.CancelledError: 104 | pass 105 | 106 | 107 | def test_session_hostname(mocker): 108 | """Create a basic IPv4 Session process from hostname""" 109 | mocker.patch('aiobfd.session.log') 110 | mocker.patch.object(aiobfd.session.Session, 'async_tx_packets', 111 | new_callable=AsyncMock) 112 | session = aiobfd.session.Session('localhost', 'localhost') 113 | aiobfd.session.log.debug.assert_called_once_with( 114 | 'Setting up UDP client for %s:%s.', 'localhost', 115 | aiobfd.session.CONTROL_PORT) 116 | aiobfd.session.log.info.assert_called_once_with( 117 | 'Sourcing traffic for %s:%s from %s:%s.', 'localhost', 118 | aiobfd.session.CONTROL_PORT, '127.0.0.1', mocker.ANY) 119 | try: 120 | session._tx_packets.cancel() # pylint: disable=I0011,W0212 121 | except asyncio.CancelledError: 122 | pass 123 | 124 | 125 | def test_session_host_force_ipv4(mocker): 126 | """Create a forced IPv4 Session process from hostname""" 127 | mocker.patch('aiobfd.session.log') 128 | mocker.patch.object(aiobfd.session.Session, 'async_tx_packets', 129 | new_callable=AsyncMock) 130 | session = aiobfd.session.Session('localhost', 'localhost', 131 | family=socket.AF_INET) 132 | aiobfd.session.log.debug.assert_called_once_with( 133 | 'Setting up UDP client for %s:%s.', 'localhost', 134 | aiobfd.session.CONTROL_PORT) 135 | aiobfd.session.log.info.assert_called_once_with( 136 | 'Sourcing traffic for %s:%s from %s:%s.', 'localhost', 137 | aiobfd.session.CONTROL_PORT, '127.0.0.1', mocker.ANY) 138 | try: 139 | session._tx_packets.cancel() # pylint: disable=I0011,W0212 140 | except asyncio.CancelledError: 141 | pass 142 | 143 | 144 | @pytest.mark.skipif(platform.node() == 'carbon', 145 | reason='IPv6 tests fail on Windows right now') 146 | def test_session_host_force_ipv6(mocker): 147 | """Create a forced IPv6 Session process from hostname""" 148 | mocker.patch('aiobfd.session.log') 149 | mocker.patch.object(aiobfd.session.Session, 'async_tx_packets', 150 | new_callable=AsyncMock) 151 | session = aiobfd.session.Session('localhost', 'localhost', 152 | family=socket.AF_INET6) 153 | aiobfd.session.log.debug.assert_called_once_with( 154 | 'Setting up UDP client for %s:%s.', 'localhost', 155 | aiobfd.session.CONTROL_PORT) 156 | aiobfd.session.log.info.assert_called_once_with( 157 | 'Sourcing traffic for %s:%s from %s:%s.', 'localhost', 158 | aiobfd.session.CONTROL_PORT, '::1', mocker.ANY) 159 | try: 160 | session._tx_packets.cancel() 161 | except asyncio.CancelledError: 162 | pass 163 | 164 | 165 | def test_sess_tx_interval_get(session): 166 | """Attempt to get the Desired Min Tx Interval""" 167 | assert session.desired_min_tx_interval == \ 168 | aiobfd.session.DESIRED_MIN_TX_INTERVAL 169 | 170 | 171 | def test_sess_tx_interval_set_same(session, mocker): 172 | """Attempt to set the Desired Min Tx Interval to same value""" 173 | mocker.patch('aiobfd.session.log') 174 | session.desired_min_tx_interval = aiobfd.session.DESIRED_MIN_TX_INTERVAL 175 | aiobfd.session.log.info.assert_not_called() 176 | 177 | 178 | def test_sess_tx_interval_set_less(session, mocker): 179 | """Attempt to set the Desired Min Tx Interval to lower value""" 180 | mocker.patch('aiobfd.session.log') 181 | session.desired_min_tx_interval = \ 182 | aiobfd.session.DESIRED_MIN_TX_INTERVAL - 1 183 | aiobfd.session.log.info.assert_called_once_with( 184 | 'bfd.DesiredMinTxInterval changed from %d to %d, starting ' 185 | 'Poll Sequence.', aiobfd.session.DESIRED_MIN_TX_INTERVAL, 186 | aiobfd.session.DESIRED_MIN_TX_INTERVAL - 1) 187 | assert session._async_tx_interval == \ 188 | aiobfd.session.DESIRED_MIN_TX_INTERVAL - 1 189 | assert session._desired_min_tx_interval == \ 190 | aiobfd.session.DESIRED_MIN_TX_INTERVAL - 1 191 | assert session.poll_sequence 192 | 193 | 194 | def test_sess_tx_interval_set_more(session, mocker): 195 | """Attempt to set the Desired Min Tx Interval to higher value""" 196 | mocker.patch('aiobfd.session.log') 197 | session.state = aiobfd.session.STATE_UP 198 | session.desired_min_tx_interval = \ 199 | aiobfd.session.DESIRED_MIN_TX_INTERVAL + 1 200 | aiobfd.session.log.info.assert_has_calls( 201 | [mocker.call( 202 | 'bfd.DesiredMinTxInterval changed from %d to %d, starting ' 203 | 'Poll Sequence.', aiobfd.session.DESIRED_MIN_TX_INTERVAL, 204 | aiobfd.session.DESIRED_MIN_TX_INTERVAL + 1), 205 | mocker.call('Delaying increase in Tx Interval from %d to %d ...', 206 | aiobfd.session.DESIRED_MIN_TX_INTERVAL, 207 | aiobfd.session.DESIRED_MIN_TX_INTERVAL + 1)]) 208 | assert session._async_tx_interval == \ 209 | aiobfd.session.DESIRED_MIN_TX_INTERVAL 210 | assert session._final_async_tx_interval == \ 211 | aiobfd.session.DESIRED_MIN_TX_INTERVAL + 1 212 | assert session._desired_min_tx_interval == \ 213 | aiobfd.session.DESIRED_MIN_TX_INTERVAL + 1 214 | assert session.poll_sequence 215 | 216 | 217 | def test_sess_rx_interval_get(session): 218 | """Attempt to get the Required Min Rx Interval""" 219 | assert session.required_min_rx_interval == 1000000 220 | 221 | 222 | def test_sess_rx_interval_set_same(session, mocker): 223 | """Attempt to set the Required Min Rx Interval to same value""" 224 | mocker.patch('aiobfd.session.log') 225 | session.required_min_rx_interval = 1000000 226 | aiobfd.session.log.info.assert_not_called() 227 | 228 | 229 | def test_sess_rx_interval_set_less(session, mocker): 230 | """Attempt to set the Required Min Rx Interval to lower value""" 231 | mocker.patch('aiobfd.session.log') 232 | session.state = aiobfd.session.STATE_UP 233 | session._remote_min_tx_interval = 900000 234 | session.remote_detect_mult = 3 235 | session.required_min_rx_interval = 1000000 - 1 236 | aiobfd.session.log.info.assert_has_calls( 237 | [mocker.call( 238 | 'bfd.RequiredMinRxInterval changed from %d to %d, starting ' 239 | 'Poll Sequence.', 1000000, 1000000 - 1), 240 | mocker.call('Delaying decrease in Detect Time from %d to %d ...', 241 | 1000000 * 3, (1000000 - 1) * 3)]) 242 | assert session._async_detect_time == (1000000) * 3 243 | assert session._final_async_detect_time == (1000000 - 1) * 3 244 | assert session._required_min_rx_interval == 1000000 - 1 245 | assert session.poll_sequence 246 | 247 | 248 | def test_sess_rx_interval_set_more(session, mocker): 249 | """Attempt to set the Required Min Rx Interval to higher value""" 250 | mocker.patch('aiobfd.session.log') 251 | session._remote_min_tx_interval = 900000 252 | session.remote_detect_mult = 3 253 | session.required_min_rx_interval = 1000000 + 1 254 | aiobfd.session.log.info.assert_called_once_with( 255 | 'bfd.RequiredMinRxInterval changed from %d to %d, starting ' 256 | 'Poll Sequence.', 1000000, 1000000 + 1) 257 | assert session._async_detect_time == (1000000 + 1) * 3 258 | assert session._required_min_rx_interval == 1000000 + 1 259 | assert session.poll_sequence 260 | 261 | 262 | def test_sess_r_rx_interval_get(session): 263 | """Attempt to get the Remote Min Rx Interval""" 264 | assert session._remote_min_rx_interval == 1 265 | 266 | 267 | def test_sess_r_rx_int_set_same(session, mocker): 268 | """Attempt to set the Remote Min Rx Interval to same value""" 269 | mocker.patch('aiobfd.session.log') 270 | session.remote_min_rx_interval = 1 271 | aiobfd.session.log.info.assert_not_called() 272 | 273 | 274 | def test_sess_r_rx_int_set_diff(session, mocker): 275 | """Attempt to set the Remote Min Rx Interval to different value""" 276 | mocker.patch('aiobfd.session.log') 277 | session.remote_min_rx_interval = 1000000 278 | aiobfd.session.log.info.assert_not_called() 279 | assert session._remote_min_rx_interval == 1000000 280 | 281 | 282 | def test_sess_r_rx_int_set_diff1(session, mocker): 283 | """Attempt to set the Remote Min Rx Interval to different value 284 | but lower than our DESIRED_MIN_TX_INTERVAL""" 285 | mocker.patch('aiobfd.session.log') 286 | session.remote_min_rx_interval = 900000 287 | aiobfd.session.log.info.assert_not_called() 288 | assert session._remote_min_rx_interval == 900000 289 | assert session._async_tx_interval == 1000000 290 | 291 | 292 | def test_sess_r_rx_int_set_diff2(session, mocker): 293 | """Attempt to set the Remote Min Rx Interval to different value 294 | but higher than our DESIRED_MIN_TX_INTERVAL""" 295 | mocker.patch('aiobfd.session.log') 296 | session.remote_min_rx_interval = 1100000 297 | aiobfd.session.log.info.assert_not_called() 298 | assert session._remote_min_rx_interval == 1100000 299 | assert session._async_tx_interval == 1100000 300 | 301 | 302 | def test_sess_r_rx_int_set_diff3(session, mocker): 303 | """Attempt to set the Remote Min Rx Interval to different value 304 | such that the Tx Interval decreases""" 305 | 306 | session.remote_min_rx_interval = 1500000 307 | mocker.patch('aiobfd.session.log') 308 | session._restart_tx_packets = MagicMock() 309 | session.remote_min_rx_interval = 900000 310 | aiobfd.session.log.info.assert_called_once_with( 311 | 'Remote triggered decrease in the Tx Interval, forcing ' 312 | 'change by restarting the Tx Packets process.') 313 | assert session._restart_tx_packets.called 314 | assert session._remote_min_rx_interval == 900000 315 | assert session._async_tx_interval == 1000000 316 | 317 | 318 | def test_sess_r_tx_interval_get(session): 319 | """Attempt to get the Remote Min Tx Interval""" 320 | assert session._remote_min_tx_interval is None 321 | 322 | 323 | def test_sess_r_tx_int_set_same(session, mocker): 324 | """Attempt to set the Remote Min Tx Interval to same value""" 325 | session.remote_min_tx_interval = 1000000 326 | mocker.patch('aiobfd.session.log') 327 | session.remote_min_tx_interval = 1000000 328 | aiobfd.session.log.info.assert_not_called() 329 | assert session._remote_min_tx_interval == 1000000 330 | 331 | 332 | def test_sess_r_tx_int_set_diff1(session, mocker): 333 | """Attempt to set the Remote Min Tx Interval to different value""" 334 | mocker.patch('aiobfd.session.log') 335 | session.remote_min_tx_interval = 1000000 336 | aiobfd.session.log.info.assert_not_called() 337 | assert session._remote_min_tx_interval == 1000000 338 | assert session._async_detect_time is None 339 | 340 | 341 | def test_sess_r_tx_int_set_diff2(session, mocker): 342 | """Attempt to set the Remote Min Tx Interval to different value""" 343 | session.remote_detect_mult = 2 344 | mocker.patch('aiobfd.session.log') 345 | session.remote_min_tx_interval = 1000000 346 | aiobfd.session.log.info.assert_not_called() 347 | assert session._remote_min_tx_interval == 1000000 348 | assert session._async_detect_time == 2000000 349 | 350 | 351 | def test_sess_r_detect_mult_get(session): 352 | """Attempt to get the Remote Detect Multiplier""" 353 | assert session._remote_detect_mult is None 354 | 355 | 356 | def test_sess_r_det_mult_set_same(session, mocker): 357 | """Attempt to set the Remote Detect Multiplier to same value""" 358 | session.remote_detect_mult = 2 359 | mocker.patch('aiobfd.session.log') 360 | session.remote_detect_mult = 2 361 | aiobfd.session.log.info.assert_not_called() 362 | assert session._remote_detect_mult == 2 363 | 364 | 365 | def test_sess_r_det_mult_set_same1(session, mocker): 366 | """Attempt to set the Remote Detect Multiplier to different value""" 367 | mocker.patch('aiobfd.session.log') 368 | session.remote_detect_mult = 2 369 | aiobfd.session.log.info.assert_not_called() 370 | assert session._remote_detect_mult == 2 371 | assert session._async_detect_time is None 372 | 373 | 374 | def test_sess_r_det_mult_set_same2(session, mocker): 375 | """Attempt to set the Remote Detect Multiplier to different value""" 376 | session.remote_min_tx_interval = 1000000 377 | mocker.patch('aiobfd.session.log') 378 | session.remote_detect_mult = 2 379 | aiobfd.session.log.info.assert_not_called() 380 | assert session._remote_detect_mult == 2 381 | assert session._async_detect_time == 2000000 382 | 383 | 384 | def test_sess_detect_time_normal1(session, mocker): 385 | """Calculate the detect time standard case""" 386 | mocker.patch('aiobfd.session.log') 387 | result = session.calc_detect_time(3, 100, 200) 388 | assert result == 600 389 | aiobfd.session.log.debug.assert_called_once_with( 390 | 'BFD Detection Time calculated using ' 391 | 'detect_mult: %s rx_interval: %s tx_interval: %s', 392 | 3, 100, 200) 393 | 394 | 395 | def test_sess_detect_time_normal2(session, mocker): 396 | """Calculate the detect time standard case""" 397 | mocker.patch('aiobfd.session.log') 398 | result = session.calc_detect_time(3, 200, 100) 399 | assert result == 600 400 | aiobfd.session.log.debug.assert_called_once_with( 401 | 'BFD Detection Time calculated using ' 402 | 'detect_mult: %s rx_interval: %s tx_interval: %s', 403 | 3, 200, 100) 404 | 405 | 406 | def test_sess_detect_time_none1(session, mocker): 407 | """Calculate the detect time standard case""" 408 | mocker.patch('aiobfd.session.log') 409 | result = session.calc_detect_time(None, 100, 200) 410 | assert result is None 411 | aiobfd.session.log.debug.assert_called_once_with( 412 | 'BFD Detection Time calculation not possible with ' 413 | 'values detect_mult: %s rx_interval: %s tx_interval: %s', 414 | None, 100, 200) 415 | 416 | 417 | def test_sess_detect_time_none2(session, mocker): 418 | """Calculate the detect time standard case""" 419 | mocker.patch('aiobfd.session.log') 420 | result = session.calc_detect_time(3, None, 200) 421 | assert result is None 422 | aiobfd.session.log.debug.assert_called_once_with( 423 | 'BFD Detection Time calculation not possible with ' 424 | 'values detect_mult: %s rx_interval: %s tx_interval: %s', 425 | 3, None, 200) 426 | 427 | 428 | def test_sess_detect_time_none3(session, mocker): 429 | """Calculate the detect time standard case""" 430 | mocker.patch('aiobfd.session.log') 431 | result = session.calc_detect_time(3, 100, None) 432 | assert result is None 433 | aiobfd.session.log.debug.assert_called_once_with( 434 | 'BFD Detection Time calculation not possible with ' 435 | 'values detect_mult: %s rx_interval: %s tx_interval: %s', 436 | 3, 100, None) 437 | 438 | 439 | @pytest.mark.asyncio # noqa: F811 440 | async def test_async_tx_pkts_mult_1(session, mocker): 441 | """Test the async detect internval with multiplier 1""" 442 | mocker.patch.object(asyncio, 'sleep', 443 | new_callable=AsyncMock) 444 | asyncio.sleep.side_effect = ErrorAfter(0) 445 | mocker.patch('aiobfd.session.log') 446 | session.detect_mult = 1 447 | with pytest.raises(CallableExhausted): 448 | await session.async_tx_packets() 449 | asyncio.sleep.assert_called_once_with(mocker.ANY) 450 | 451 | 452 | @pytest.mark.asyncio # noqa: F811 453 | async def test_async_tx_pkts_mult_2(session, mocker): 454 | """Test the async detect internval with multiplier 2""" 455 | mocker.patch.object(asyncio, 'sleep', 456 | new_callable=AsyncMock) 457 | asyncio.sleep.side_effect = ErrorAfter(0) 458 | mocker.patch('aiobfd.session.log') 459 | session.detect_mult = 2 460 | with pytest.raises(CallableExhausted): 461 | await session.async_tx_packets() 462 | asyncio.sleep.assert_called_once_with(mocker.ANY) 463 | 464 | 465 | @pytest.mark.asyncio # noqa: F811 466 | async def test_async_tx_pkt_passive1(session, mocker): 467 | """Test whether we send packets when the session is passive""" 468 | mocker.patch.object(asyncio, 'sleep', 469 | new_callable=AsyncMock) 470 | asyncio.sleep.side_effect = ErrorAfter(0) 471 | mocker.patch.object(session, 'tx_packet') 472 | session.passive = True 473 | with pytest.raises(CallableExhausted): 474 | await session.async_tx_packets() 475 | session.tx_packet.assert_not_called() 476 | 477 | 478 | @pytest.mark.asyncio # noqa: F811 479 | async def test_async_tx_pkt_passive2(session, mocker): 480 | """Test whether we send packets when passive but remote discr known""" 481 | mocker.patch.object(asyncio, 'sleep', 482 | new_callable=AsyncMock) 483 | asyncio.sleep.side_effect = ErrorAfter(0) 484 | mocker.patch.object(session, 'tx_packet') 485 | session.passive = True 486 | session.remote_discr = 22 487 | with pytest.raises(CallableExhausted): 488 | await session.async_tx_packets() 489 | session.tx_packet.assert_called_once_with() 490 | 491 | 492 | @pytest.mark.asyncio # noqa: F811 493 | async def test_async_tx_pkt_rem_rx_0(session, mocker): 494 | """Test whether we send packets when the remote Rx Interval is 0""" 495 | mocker.patch.object(asyncio, 'sleep', 496 | new_callable=AsyncMock) 497 | asyncio.sleep.side_effect = ErrorAfter(0) 498 | mocker.patch.object(session, 'tx_packet') 499 | session.remote_min_rx_interval = 0 500 | with pytest.raises(CallableExhausted): 501 | await session.async_tx_packets() 502 | session.tx_packet.assert_not_called() 503 | 504 | 505 | @pytest.mark.asyncio # noqa: F811 506 | async def test_async_tx_pkt_demand1(session, mocker): 507 | """Test whether we send packets when the remote is in demand mode""" 508 | mocker.patch.object(asyncio, 'sleep', 509 | new_callable=AsyncMock) 510 | asyncio.sleep.side_effect = ErrorAfter(0) 511 | mocker.patch.object(session, 'tx_packet') 512 | session.remote_demand_mode = True 513 | session.state = aiobfd.session.STATE_UP 514 | session.remote_state = aiobfd.session.STATE_UP 515 | with pytest.raises(CallableExhausted): 516 | await session.async_tx_packets() 517 | session.tx_packet.assert_not_called() 518 | 519 | 520 | @pytest.mark.asyncio # noqa: F811 521 | async def test_async_tx_pkt_demand2(session, mocker): 522 | """Test whether we send packets when the remote is in demand mode and we 523 | have initiated a poll sequence.""" 524 | mocker.patch.object(asyncio, 'sleep', 525 | new_callable=AsyncMock) 526 | asyncio.sleep.side_effect = ErrorAfter(0) 527 | mocker.patch.object(session, 'tx_packet') 528 | session.remote_demand_mode = True 529 | session.state = aiobfd.session.STATE_UP 530 | session.remote_state = aiobfd.session.STATE_UP 531 | session.poll_sequence = True 532 | with pytest.raises(CallableExhausted): 533 | await session.async_tx_packets() 534 | session.tx_packet.assert_called_once_with() 535 | 536 | 537 | def test_tx_packet(session, mocker): 538 | """Test whether tx_packet() sends packets to client""" 539 | mocker.patch('aiobfd.session.log') 540 | mocker.patch.object(session, 'encode_packet') 541 | mocker.patch.object(session, 'client') 542 | session.encode_packet.return_value = 'under_test' 543 | session.tx_packet() 544 | session.encode_packet.assert_called_once_with(False) 545 | session.client.sendto.assert_called_once_with( 546 | mocker.ANY, ('127.0.0.1', aiobfd.session.CONTROL_PORT)) 547 | aiobfd.session.log.debug.assert_called_once_with( 548 | 'Transmitting BFD packet to %s:%s', '127.0.0.1', 549 | aiobfd.session.CONTROL_PORT) 550 | 551 | 552 | def test_restart_tx_packets(session, mocker): 553 | """Test the restart_tx_packet() procedure""" 554 | mocker.patch('aiobfd.session.log') 555 | session._restart_tx_packets() 556 | aiobfd.session.log.info.assert_has_calls( 557 | [mocker.call('Attempting to cancel tx_packets() ...'), 558 | mocker.call('Restarting tx_packets() ...')]) 559 | 560 | 561 | @pytest.mark.asyncio # noqa: F811 562 | async def test_detect_down(session, mocker): 563 | """Test the detection logic, really down""" 564 | session.required_min_rx_interval = 4000 565 | session.remote_detect_mult = 3 566 | session.remote_min_tx_interval = 2000 567 | session.last_rx_packet_time = time.time() 568 | session.state = aiobfd.session.STATE_UP 569 | await asyncio.sleep(((3 * 4000) + 1000)/1000000) 570 | mocker.patch.object(asyncio, 'sleep', 571 | new_callable=AsyncMock) 572 | asyncio.sleep.side_effect = ErrorAfter(1) 573 | mocker.patch('aiobfd.session.log') 574 | with pytest.raises(CallableExhausted): 575 | await session.detect_async_failure() 576 | assert session.state == aiobfd.session.STATE_DOWN 577 | assert session.local_diag == aiobfd.session.DIAG_CONTROL_DETECTION_EXPIRED 578 | assert session.desired_min_tx_interval == \ 579 | aiobfd.session.DESIRED_MIN_TX_INTERVAL 580 | aiobfd.session.log.critical.assert_called_once_with( 581 | 'Detected BFD remote %s going DOWN!', '127.0.0.1') 582 | aiobfd.session.log.info.assert_called_once_with( 583 | 'Time since last packet: %d ms; Detect Time: %d ms', 584 | mocker.ANY, ((3 * 4000))/1000) 585 | 586 | 587 | @pytest.mark.asyncio # noqa: F811 588 | async def test_detect_up(session, mocker): 589 | """Test the detection logic, still up""" 590 | session.required_min_rx_interval = 4000 591 | session.remote_detect_mult = 3 592 | session.remote_min_tx_interval = 2000 593 | session.last_rx_packet_time = time.time() 594 | session.state = aiobfd.session.STATE_UP 595 | await asyncio.sleep(((4000))/1000000) 596 | mocker.patch.object(asyncio, 'sleep', 597 | new_callable=AsyncMock) 598 | asyncio.sleep.side_effect = ErrorAfter(1) 599 | mocker.patch('aiobfd.session.log') 600 | with pytest.raises(CallableExhausted): 601 | await session.detect_async_failure() 602 | aiobfd.session.log.critical.assert_not_called() 603 | aiobfd.session.log.info.assert_not_called() 604 | 605 | 606 | @pytest.mark.asyncio # noqa: F811 607 | async def test_detect_demand_mode(session, mocker): 608 | """Test the detection logic, in demand mode""" 609 | session.required_min_rx_interval = 4000 610 | session.remote_detect_mult = 3 611 | session.remote_min_tx_interval = 2000 612 | session.last_rx_packet_time = time.time() 613 | session.state = aiobfd.session.STATE_UP 614 | session.demand_mode = True 615 | await asyncio.sleep(((3 * 4000) + 1000)/1000000) 616 | mocker.patch.object(asyncio, 'sleep', 617 | new_callable=AsyncMock) 618 | asyncio.sleep.side_effect = ErrorAfter(1) 619 | mocker.patch('aiobfd.session.log') 620 | with pytest.raises(CallableExhausted): 621 | await session.detect_async_failure() 622 | aiobfd.session.log.critical.assert_not_called() 623 | aiobfd.session.log.info.assert_not_called() 624 | 625 | 626 | @pytest.mark.asyncio # noqa: F811 627 | async def test_detect_no_detect_time(session, mocker): 628 | """Test the detection logic, no detect_time set""" 629 | session.required_min_rx_interval = 4000 630 | session.remote_detect_mult = 3 631 | session.remote_min_tx_interval = 2000 632 | session.last_rx_packet_time = time.time() 633 | session.state = aiobfd.session.STATE_UP 634 | session._async_detect_time = None 635 | await asyncio.sleep(((3 * 4000) + 1000)/1000000) 636 | mocker.patch.object(asyncio, 'sleep', 637 | new_callable=AsyncMock) 638 | asyncio.sleep.side_effect = ErrorAfter(1) 639 | mocker.patch('aiobfd.session.log') 640 | with pytest.raises(CallableExhausted): 641 | await session.detect_async_failure() 642 | aiobfd.session.log.critical.assert_not_called() 643 | aiobfd.session.log.info.assert_not_called() 644 | 645 | 646 | @pytest.mark.asyncio # noqa: F811 647 | async def test_detect_state_init(session, mocker): 648 | """Test the detection logic, in init state""" 649 | session.required_min_rx_interval = 4000 650 | session.remote_detect_mult = 3 651 | session.remote_min_tx_interval = 2000 652 | session.last_rx_packet_time = time.time() 653 | session.state = aiobfd.session.STATE_INIT 654 | await asyncio.sleep(((3 * 4000) + 1000)/1000000) 655 | mocker.patch.object(asyncio, 'sleep', 656 | new_callable=AsyncMock) 657 | asyncio.sleep.side_effect = ErrorAfter(1) 658 | mocker.patch('aiobfd.session.log') 659 | with pytest.raises(CallableExhausted): 660 | await session.detect_async_failure() 661 | assert session.state == aiobfd.session.STATE_DOWN 662 | assert session.local_diag == aiobfd.session.DIAG_CONTROL_DETECTION_EXPIRED 663 | assert session.desired_min_tx_interval == \ 664 | aiobfd.session.DESIRED_MIN_TX_INTERVAL 665 | aiobfd.session.log.critical.assert_called_once_with( 666 | 'Detected BFD remote %s going DOWN!', '127.0.0.1') 667 | aiobfd.session.log.info.assert_called_once_with( 668 | 'Time since last packet: %d ms; Detect Time: %d ms', 669 | mocker.ANY, ((3 * 4000))/1000) 670 | 671 | 672 | @pytest.mark.asyncio # noqa: F811 673 | async def test_detect_state_admin_down(session, mocker): 674 | """Test the detection logic, in admin down state""" 675 | session.required_min_rx_interval = 4000 676 | session.remote_detect_mult = 3 677 | session.remote_min_tx_interval = 2000 678 | session.last_rx_packet_time = time.time() 679 | session.state = aiobfd.session.STATE_ADMIN_DOWN 680 | await asyncio.sleep(((3 * 4000) + 1000)/1000000) 681 | mocker.patch.object(asyncio, 'sleep', 682 | new_callable=AsyncMock) 683 | asyncio.sleep.side_effect = ErrorAfter(1) 684 | mocker.patch('aiobfd.session.log') 685 | with pytest.raises(CallableExhausted): 686 | await session.detect_async_failure() 687 | aiobfd.session.log.critical.assert_not_called() 688 | aiobfd.session.log.info.assert_not_called() 689 | 690 | 691 | @pytest.mark.asyncio # noqa: F811 692 | async def test_detect_state_down(session, mocker): 693 | """Test the detection logic, in down state""" 694 | session.required_min_rx_interval = 4000 695 | session.remote_detect_mult = 3 696 | session.remote_min_tx_interval = 2000 697 | session.last_rx_packet_time = time.time() 698 | session.state = aiobfd.session.STATE_DOWN 699 | await asyncio.sleep(((3 * 4000) + 1000)/1000000) 700 | mocker.patch.object(asyncio, 'sleep', 701 | new_callable=AsyncMock) 702 | asyncio.sleep.side_effect = ErrorAfter(1) 703 | mocker.patch('aiobfd.session.log') 704 | with pytest.raises(CallableExhausted): 705 | await session.detect_async_failure() 706 | aiobfd.session.log.critical.assert_not_called() 707 | aiobfd.session.log.info.assert_not_called() 708 | 709 | 710 | def test_rx_packet_auth_bit(session, valid_packet, mocker): 711 | """Test whether A bit is set while we are not configured for auth""" 712 | mocker.patch('aiobfd.session.log') 713 | valid_packet.authentication_present = True 714 | with pytest.raises(IOError) as excinfo: 715 | session.rx_packet(valid_packet) 716 | assert 'Received packet with authentication while no '\ 717 | 'authentication is configured locally.' in str(excinfo.value) 718 | 719 | 720 | def test_rx_packet_auth_sess(session, valid_packet, mocker): 721 | """Test whether A bit is unset while we are configured for auth""" 722 | mocker.patch('aiobfd.session.log') 723 | session.auth_type = 1 724 | with pytest.raises(IOError) as excinfo: 725 | session.rx_packet(valid_packet) 726 | assert 'Received packet without authentication while '\ 727 | 'authentication is configured locally.' in str(excinfo.value) 728 | 729 | 730 | def test_rx_packet_auth_both(session, valid_packet, mocker): 731 | """Authentication configured but not implemented !?""" 732 | mocker.patch('aiobfd.session.log') 733 | session.auth_type = 1 734 | valid_packet.authentication_present = True 735 | session.rx_packet(valid_packet) 736 | aiobfd.session.log.critical.assert_called_once_with( 737 | 'Authenticated packet received, not supported!') 738 | 739 | 740 | def test_rx_packet_remote_update(session, valid_packet, mocker): 741 | """Check whether we store all info from the remote""" 742 | mocker.patch('aiobfd.session.log') 743 | valid_packet.my_discr = 12345 744 | valid_packet.state = 2 745 | session.rx_packet(valid_packet) 746 | assert session.remote_discr == 12345 747 | assert session.remote_state == 2 748 | assert session.remote_demand_mode == 0 749 | assert session.remote_min_rx_interval == 50000 750 | assert session.remote_detect_mult == 1 751 | assert session.remote_min_tx_interval == 50000 752 | assert session.last_rx_packet_time is not None 753 | aiobfd.session.log.debug.assert_has_calls( 754 | [mocker.call( 755 | 'Valid packet received from %s, updating last packet time.', 756 | '127.0.0.1')]) 757 | 758 | 759 | def test_rx_packet_poll(session, valid_packet, mocker): 760 | """Check whether the P bit is acted on""" 761 | mocker.patch('aiobfd.session.log') 762 | mocker.patch.object(session, 'tx_packet') 763 | valid_packet.poll = True 764 | session.rx_packet(valid_packet) 765 | session.tx_packet.assert_called_once_with(final=True) 766 | aiobfd.session.log.info.assert_called_once_with( 767 | 'Received packet with Poll (P) bit set from %s, ' 768 | 'sending packet with Final (F) bit set.', '127.0.0.1') 769 | 770 | 771 | def test_rx_packet_final(session, valid_packet, mocker): 772 | """Check whether the F bit is acted on""" 773 | mocker.patch('aiobfd.session.log') 774 | valid_packet.final = True 775 | session.poll_sequence = True 776 | session.rx_packet(valid_packet) 777 | assert session.poll_sequence is False 778 | aiobfd.session.log.info.assert_called_once_with( 779 | 'Received packet with Final (F) bit set from %s, ' 780 | 'ending Poll Sequence.', '127.0.0.1') 781 | 782 | 783 | def test_rx_pkt_final_tx_interval(session, valid_packet, mocker): 784 | """Check whether the final async tx interval is applied""" 785 | mocker.patch('aiobfd.session.log') 786 | valid_packet.final = True 787 | session.poll_sequence = True 788 | session._final_async_tx_interval = 123000 789 | session.rx_packet(valid_packet) 790 | assert session.poll_sequence is False 791 | aiobfd.session.log.info.assert_has_calls( 792 | [mocker.call('Increasing Tx Interval from %d to %d now that Poll ' 793 | 'Sequence has ended.', 1000000, 123000)]) 794 | assert session._async_tx_interval == 123000 795 | assert session._final_async_tx_interval is None 796 | 797 | 798 | def test_rx_pkt_final_detect_time(session, valid_packet, mocker): 799 | """Check whether the final async detect time is applied""" 800 | mocker.patch('aiobfd.session.log') 801 | valid_packet.final = True 802 | session.poll_sequence = True 803 | session._final_async_detect_time = 123000 804 | session.rx_packet(valid_packet) 805 | assert session.poll_sequence is False 806 | aiobfd.session.log.info.assert_has_calls( 807 | [mocker.call('Increasing Detect Time from %d to %d now that Poll ' 808 | 'Sequence has ended.', 1000000, 123000)]) 809 | assert session._async_detect_time == 123000 810 | assert session._final_async_detect_time is None 811 | 812 | 813 | def test_rx_pkt_fsm_this_admin_down(session, valid_packet, mocker): 814 | """Check whether the FSM on Rx Packet works in admin down""" 815 | mocker.patch('aiobfd.session.log') 816 | session.state = aiobfd.session.STATE_ADMIN_DOWN 817 | session.rx_packet(valid_packet) 818 | aiobfd.session.log.warning.assert_called_once_with( 819 | 'Received packet from %s while in Admin Down state.', '127.0.0.1') 820 | 821 | 822 | def test_rx_pkt_fsm_admin_down(session, valid_packet, mocker): 823 | """Check whether the FSM on Rx Packet with admin down""" 824 | mocker.patch('aiobfd.session.log') 825 | session.state = aiobfd.session.STATE_UP 826 | valid_packet.state = aiobfd.session.STATE_ADMIN_DOWN 827 | session.rx_packet(valid_packet) 828 | aiobfd.session.log.error.assert_called_once_with( 829 | 'BFD remote %s signaled going ADMIN_DOWN.', '127.0.0.1') 830 | assert session.local_diag == aiobfd.session.DIAG_NEIGHBOR_SIGNAL_DOWN 831 | assert session.state == aiobfd.session.STATE_DOWN 832 | 833 | 834 | def test_rx_pkt_fsm_down(session, valid_packet, mocker): 835 | """Check whether the FSM on Rx Packet with down""" 836 | mocker.patch('aiobfd.session.log') 837 | session.state = aiobfd.session.STATE_UP 838 | valid_packet.state = aiobfd.session.STATE_DOWN 839 | session.rx_packet(valid_packet) 840 | aiobfd.session.log.error.assert_called_once_with( 841 | 'BFD remote %s signaled going DOWN.', '127.0.0.1') 842 | assert session.local_diag == aiobfd.session.DIAG_NEIGHBOR_SIGNAL_DOWN 843 | assert session.state == aiobfd.session.STATE_DOWN 844 | 845 | 846 | def test_rx_pkt_fsm_down_down(session, valid_packet, mocker): 847 | """Check whether the FSM on Rx Packet with local & remote down""" 848 | mocker.patch('aiobfd.session.log') 849 | session.state = aiobfd.session.STATE_DOWN 850 | valid_packet.state = aiobfd.session.STATE_DOWN 851 | session.rx_packet(valid_packet) 852 | aiobfd.session.log.error.assert_called_once_with( 853 | 'BFD session with %s going to INIT state.', '127.0.0.1') 854 | assert session.state == aiobfd.session.STATE_INIT 855 | 856 | 857 | def test_rx_pkt_fsm_down_init(session, valid_packet, mocker): 858 | """Check whether the FSM on Rx Packet with local down & remote down""" 859 | mocker.patch('aiobfd.session.log') 860 | session.state = aiobfd.session.STATE_DOWN 861 | session.tx_interval = 5000 862 | valid_packet.state = aiobfd.session.STATE_INIT 863 | session.rx_packet(valid_packet) 864 | aiobfd.session.log.error.assert_called_once_with( 865 | 'BFD session with %s going to UP state.', '127.0.0.1') 866 | assert session.state == aiobfd.session.STATE_UP 867 | assert session.desired_min_tx_interval == 5000 868 | 869 | 870 | def test_rx_pkt_fsm_init_init(session, valid_packet, mocker): 871 | """Check whether the FSM on Rx Packet with local & remote init""" 872 | mocker.patch('aiobfd.session.log') 873 | session.state = aiobfd.session.STATE_INIT 874 | session.tx_interval = 5000 875 | valid_packet.state = aiobfd.session.STATE_INIT 876 | session.rx_packet(valid_packet) 877 | aiobfd.session.log.error.assert_called_once_with( 878 | 'BFD session with %s going to UP state.', '127.0.0.1') 879 | assert session.state == aiobfd.session.STATE_UP 880 | assert session.desired_min_tx_interval == 5000 881 | 882 | 883 | def test_rx_pkt_fsm_init_up(session, valid_packet, mocker): 884 | """Check whether the FSM on Rx Packet with local init & remote up""" 885 | mocker.patch('aiobfd.session.log') 886 | session.state = aiobfd.session.STATE_INIT 887 | session.tx_interval = 5000 888 | valid_packet.state = aiobfd.session.STATE_UP 889 | session.rx_packet(valid_packet) 890 | aiobfd.session.log.error.assert_called_once_with( 891 | 'BFD session with %s going to UP state.', '127.0.0.1') 892 | assert session.state == aiobfd.session.STATE_UP 893 | assert session.desired_min_tx_interval == 5000 894 | -------------------------------------------------------------------------------- /tests/test_transport.py: -------------------------------------------------------------------------------- 1 | """Test aiobfd/transport.py""" 2 | # pylint: disable=I0011,W0621,E1101 3 | 4 | import asyncio 5 | import pytest 6 | import aiobfd.transport 7 | 8 | 9 | @pytest.fixture(scope='session') 10 | def client(): 11 | """Create an aoibfd client""" 12 | return aiobfd.transport.Client() 13 | 14 | 15 | @pytest.fixture(scope='session') 16 | def server(): 17 | """Create an aoibfd server""" 18 | rx_queue = asyncio.Queue() 19 | return aiobfd.transport.Server(rx_queue) 20 | 21 | 22 | def test_client_connection_made(client): 23 | """Test whether we can establish client connections""" 24 | client.connection_made(None) 25 | 26 | 27 | def test_client_datagram_received(client, mocker): 28 | """Test whether receiving packets on a client port creates a log entry""" 29 | mocker.patch('aiobfd.transport.log') 30 | client.datagram_received('data', ('127.0.0.1', 12345)) 31 | aiobfd.transport.log.info.assert_called_once_with( 32 | 'Unexpectedly received a packet on a BFD source port from %s on port ' 33 | '%d', '127.0.0.1', 12345) 34 | 35 | 36 | def test_client_error_received(client, mocker): 37 | """Test whether receiving errors on a client creates a log entry""" 38 | mocker.patch('aiobfd.transport.log') 39 | client.error_received('test error') 40 | aiobfd.transport.log.error.assert_called_once_with( 41 | 'Socket error received: %s', 'test error') 42 | 43 | 44 | def test_server_connection_made(server): 45 | """Test whether we can establish a server connections""" 46 | server.connection_made(None) 47 | 48 | 49 | def test_server_datagram_received(server): 50 | """Test whether receiving packets on the server queues them""" 51 | server.datagram_received('data', ('127.0.0.1', 12345)) 52 | 53 | 54 | def test_server_error_received(server, mocker): 55 | """Test whether receiving errors on a server creates a log entry""" 56 | mocker.patch('aiobfd.transport.log') 57 | server.error_received('test error') 58 | aiobfd.transport.log.error.assert_called_once_with( 59 | 'Socket error received: %s', 'test error') 60 | --------------------------------------------------------------------------------