├── .gitignore ├── LICENSE ├── README.md ├── examples ├── ping.py ├── trace-concurrent.py └── trace-sequential.py ├── mtrpacket └── __init__.py ├── setup.py ├── test.sh └── test ├── nc_mock.sh └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | __pycache__ 3 | 4 | .mypy_cache 5 | build 6 | dist 7 | *.egg-info 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | mtrpacket - Asynchronous network probes for Python 2 | Copyright (c) 2019 Matt Kimball 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to 6 | deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mtrpacket 2 | 3 | ### Asynchronous network probes for Python 4 | 5 | `mtrpacket` is a Python 3 package for sending IPv4 and IPv6 network probes ('pings') asynchronously from Python programs. Python's `asyncio` library provides the event loop and mechanism for incorporating `mtrpacket`'s network probes with other concurrent operations. 6 | 7 | `mtrpacket` supports a variety of probe customization options. Time-to-live (TTL) may be explicitly used for `traceroute`-like functionality. Probes can be sent using a variety of protocols: ICMP, UDP, TCP and SCTP. UDP, TCP and SCTP probes may be sent with specific source and destination ports. Probes can be sent with a particular packet size and payload bit-pattern. On Linux, probes can be sent with a routing "mark". 8 | 9 | `mtrpacket` works on Linux, MacOS, Windows (under Cygwin) and various Unix systems. Requirements are Python 3.5 (or newer) and `mtr` 0.88 (or newer). `mtr` is distributed with many Linux distributions -- you may have it installed already. For other operating systems, see [the mtr Github repository](https://github.com/traviscross/mtr). 10 | 11 | ## Installation 12 | 13 | To install mtrpacket, use the Python 3 version of pip: 14 | 15 | `pip3 install mtrpacket` 16 | 17 | ## Getting started 18 | 19 | The easiest way to get started with mtrpacket is to use `async with` to open an mtrpacket session, and then call probe on that session. This must be done in an `asyncio` coroutine. `asyncio` manages the event loop. 20 | 21 | ```python 22 | import asyncio 23 | import mtrpacket 24 | 25 | # A simple coroutine which will start an mtrpacket session and 26 | # ping localhost 27 | async def probe(): 28 | async with mtrpacket.MtrPacket() as mtr: 29 | return await mtr.probe('localhost') 30 | 31 | # Use asyncio's event loop to start the coroutine and wait for the probe 32 | loop = asyncio.get_event_loop() 33 | try: 34 | result = loop.run_until_complete(probe()) 35 | finally: 36 | loop.close() 37 | 38 | # Print the probe result 39 | print(result) 40 | ``` 41 | 42 | Keyword arguments may be used with `mtr.probe` to further customize the network probe. 43 | 44 | ```python 45 | # Send a probe to the HTTPS port of example.com and limit the probe 46 | # to four network hops 47 | result = await mtr.probe( 48 | 'example.com', 49 | ttl=4, 50 | protocol='tcp', 51 | port=443) 52 | ``` 53 | 54 | ## Further Examples 55 | 56 | Further examples of usage are available in [the mtrpacket GitHub repository](https://github.com/matt-kimball/mtr-packet-python/tree/master/examples) 57 | 58 | ## Compatibility Notes 59 | 60 | mtr version 0.93 has a known issue where a probe cannot be created without specifying a local IP address. This will result in 'invalid-argument' results from sent probes. You can work around this issue by specifying a local ip address when sending a probe: 61 | 62 | ```python 63 | import socket 64 | 65 | local_addr = socket.gethostbyname(socket.gethostname()) 66 | result = await mtr.probe('example.com', local_ip=local_addr) 67 | ``` 68 | 69 | ## API Reference 70 | 71 | ### MtrPacket 72 | #### *class* `MtrPacket()` 73 | 74 | `MtrPacket` is a channel for communicating with a subprocess running the `mtr-packet` executable. Multiple simultaneous probe requests can be made through a single `MtrPacket` instance, with results processed asynchronously, as they arrive. 75 | 76 | The `mtr-packet` executable is distributed with versions of `mtr` since version 0.88. 77 | 78 | #### *coroutine* `MtrPacket.open()` 79 | 80 | `open` start a subprocess for sending and receiving network probes. The`'mtr-packet` executable found at a location in the environment `PATH` is used by default, however, the environment variable `MTR_PACKET` can be used to override this behavior, invoking an alternate subprocess executable. 81 | 82 | Rather than calling `open` explicitly, the usual alternative is to use an `MtrPacket` instance in an `async with` block. This will launch the subprocess for the duration of the block, and terminate the subprocess when the block is exited. 83 | 84 | `open` returns the `MtrPacket` object on which `open` has been invoked. This can be safely ignored. 85 | 86 | `ProcessError` is raised if the subprocess fails to execute, or if the subprocess executable doesn't support the expected interface. 87 | 88 | `StateError` is raised if the `MtrPacket` object is already open. 89 | 90 | #### *coroutine* `MtrPacket.close()` 91 | 92 | If `open` was explicitly called to start the subprocess, then `close` should be called to terminate the subprocess and clean up its resources. 93 | 94 | #### *coroutine* `MtrPacket.check_support(`*feature_name*`)` 95 | 96 | `check_support` can be used to check support for particular features in the `mtr-packet` subprocess. A string is provided with the name of a feature to check, and `True` is returned if the feature is supported, `False` otherwise. 97 | 98 | The strings `'udp'`, `'tcp'` and `'sctp'` can be used as feature names to check support for UDP probes, TCP probes, and SCTP probes, respectively. 99 | 100 | See `check-support` in the `mtr-packet(8)` man page for more information. 101 | 102 | `ProcessError` is raised if the `mtr-packet` subprocess has unexpectedly terminated. 103 | 104 | `StateError` is raised if the `MtrPacket` session hasn't been opened. 105 | 106 | #### *coroutine* `MtrPacket.probe(`*hostname*, ...`)` 107 | 108 | Send a network probe to a particular hostname or IP address, and upon completion, return a `ProbeResult` containing the status of the probe, the address of the host responding to the probe and the round trip time of the probe. 109 | 110 | ##### Keyword arguments 111 | 112 | A number of optional keyword arguments can be used with `MtrPacket.probe`: 113 | 114 | `ip_version` 115 | 116 | Either `4` or `6`, indicating that the IP protocol version to use should be either IPv4 or IPv6. If unspecified, the appropriate IP protocol version will be determined using the network configuration of the local host and the DNS resolved IP addresses. 117 | 118 | `ttl` 119 | 120 | An integer (0-255) for the "time to live" of the probe request. This is used to limit the number of network hops the probe traverses before the probe result is returned to the origination point. The default "time to live" for the probe is 255. 121 | 122 | `protocol` 123 | 124 | A string representing the protocol to use when sending the probe. `'icmp'`, `'udp'`, `'tcp'` and `'sctp'` are recognized options. Protocols other than `'icmp'` are not supported on all `mtr-packet` implementations. If portability between `mtr-packet` implementations is desired, then`check_support` should be used to determine whether a particular protocol is supported before use. The default protocol is `'icmp'`. 125 | 126 | `port` 127 | 128 | An integer to use as the destination port for `'udp'`, `'tcp'` or `'sctp'` probes. 129 | 130 | `local_ip` 131 | 132 | An IP address string used to set the source address of the probe to be a particular IP address. This can be useful when sending from a host with multiple local IP addresses. The default local address is determined using the network configuration. 133 | 134 | `local_port` 135 | 136 | An integer to use to send the probe from a particular local port, when sending `'udp'`, `'tcp'` or `'sctp'` probes. 137 | 138 | `timeout` 139 | 140 | An integer specifying the number of seconds to wait for a response before assuming the probe has been lost. The default value is ten seconds. 141 | 142 | `size` 143 | 144 | An integer specifying the size of the generated probe packet, in bytes. The default value is the minimum size possible for a packet of the particular IP version and protocol in use. The maximum size is the maximum transmission unit ("MTU") of the local network configuration. 145 | 146 | `bit_pattern` 147 | 148 | An integer byte value used to fill the payload of the probe packet. In some very rare cases, network performance can vary based on the contents of network packets. This option can be used to measure such cases. 149 | 150 | `tos` 151 | 152 | An integer value for the "type of service" field for IPv4 packets, or the "traffic class" field of IPv6 packets. 153 | 154 | `mark` 155 | 156 | An integer value to use as the packet "mark" for the Linux routing subsystem. 157 | 158 | ##### Exceptions 159 | 160 | `ProcessError` is raised if the mtr-packet subprocess has unexpectedly terminated. 161 | 162 | `HostResolveError` is raised if the hostname can't be resolved to an IP address. 163 | 164 | `StateError` is raised if the MtrPacket session hasn't been opened. 165 | 166 | #### `MtrPacket.clear_dns_cache()` 167 | 168 | For performance reasons, when repeatedly probing a particular host, MtrPacket will only resolve the hostname one time, and will use the same IP address for subsequent probes to the same host. 169 | 170 | `clear_dns_cache` can be used to clear that cache, forcing new resolution of hostnames to IP addresses for future probes. This can be useful for scripts which are intended to run for an extended period of time. (Hours or longer) 171 | 172 | ### ProbeResult 173 | #### *namedtuple* `ProbeResult(`*success*, *result*, *time_ms*, *responder*, *mpls*`)` 174 | 175 | A call to `MtrPacket.probe` will result in an instance of the named tuple `ProbeResult`, which contains the following fields: 176 | 177 | #### `ProbeResult.success` 178 | 179 | A boolean which is `True` only if the probe arrived at the target host. 180 | 181 | #### `ProbeResult.result` 182 | 183 | The command reply string from `mtr-packet`. Common values are `'reply'` for a probe which arrives at the target host, `'ttl-expired'` for a probe which has its "time to live" counter reach zero before arriving at the target host, and `'no-reply'` for a probe which is unanswered before its timeout value. 184 | 185 | See the `mtr-packet(8)` man page for error conditions which may result in other command reply strings. 186 | 187 | #### `ProbeResult.time_ms` 188 | 189 | A floating point value indicating the number of milliseconds the probe was in-transit, prior to receiving a result. This value will be `None` in cases other than `'reply'` or `'ttl-expired'`. 190 | 191 | #### `ProbeResult.responder` 192 | 193 | A string with the IP address of the host responding to the probe. Will be `None` in cases other than `'reply'` or `'ttl-expired'`. 194 | 195 | #### `ProbeResult.mpls` 196 | 197 | A list of `Mpls` named tuples representing the MPLS label stack present in a `'ttl-expired'` response, when Multiprotocol Label Switching (MPLS) is used to route the probe. 198 | 199 | ### Mpls 200 | #### *namedtuple* `Mpls(`*label*, *traffic_class*, *bottom_of_stack*, *ttl*`)` 201 | 202 | Multiprotocol Label Switching ("MPLS") routes packets using explicit headers attach to the packet, rather than using the IP address for routing. When a probe's time-to-live ("TTL") expires, and MPLS is used at the router where the expiration occurs, the MPLS headers attached to the packet may be returned with the TTL expiration notification. 203 | 204 | The `Mpls` named tuple contains the fields of one of those headers, with: 205 | 206 | #### `Mpls.label` 207 | 208 | The MPLS label as an integer. 209 | 210 | #### `Mpls.traffic_class` 211 | 212 | The integer traffic class value (for quality of service). In prior verisons of MPLS, this field was known as "experimental use". 213 | 214 | #### `Mpls.bottom_of_stack` 215 | 216 | A boolean indicating whether the label terminates the stack. 217 | 218 | #### `Mpls.ttl` 219 | 220 | An integer with the "time to live" value of the MPLS header 221 | 222 | ### Exceptions 223 | #### *exception* `StateError` 224 | 225 | StateError is raised when attempting to send a command to the mtr-packet subprocess without first opening the MtrPacket subprocess, or when attempting to open a subprocess which is already open. 226 | 227 | #### *exception* `HostResolveError` 228 | 229 | If a hostname is passed to MtrPacket.probe, and that hostname fails to resolve to an IP address, `HostResolveError` is raised. 230 | 231 | #### *exception* `ProcessError` 232 | 233 | ProcessError is raised by a call to `MtrPacket.probe` or `MtrPacket.check_support` when the `mtr-packet` subprocess has unexpectly terminated. It is also raised by `MtrPacket.open` when the subprocess doesn't respond using the expected `mtr-packet` interface. 234 | -------------------------------------------------------------------------------- /examples/ping.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import mtrpacket 4 | 5 | 6 | # 7 | # We send the probe in a coroutine since mtrpacket operates 8 | # asynchronously. In a more complicated program, this 9 | # allows other asynchronous operations to occur concurrently 10 | # with the probe. 11 | # 12 | async def probe(host): 13 | async with mtrpacket.MtrPacket() as mtr: 14 | result = await mtr.probe(host) 15 | 16 | # If the ping got a reply, report the IP address and time 17 | if result.success: 18 | print('reply from {} in {} ms'.format( 19 | result.responder, result.time_ms)) 20 | else: 21 | print('no reply ({})'.format(result.result)) 22 | 23 | 24 | # Get a hostname to ping from the commandline 25 | if len(sys.argv) > 1: 26 | hostname = sys.argv[1] 27 | else: 28 | print('Usage: python3 ping.py ') 29 | sys.exit(1) 30 | 31 | 32 | # We need asyncio's event loop to run the coroutine 33 | loop = asyncio.get_event_loop() 34 | try: 35 | probe_coroutine = probe(hostname) 36 | try: 37 | loop.run_until_complete(probe_coroutine) 38 | except mtrpacket.HostResolveError: 39 | print("Can't resolve host '{}'".format(hostname)) 40 | finally: 41 | loop.close() 42 | -------------------------------------------------------------------------------- /examples/trace-concurrent.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import curses 3 | import sys 4 | 5 | import mtrpacket 6 | 7 | 8 | # 9 | # ProbeRecord keeps a record of round-trip times of probes and repsonder 10 | # IP addresses, for a particular time-to-live (TTL) value. 11 | # 12 | # There may be multiple IP addresses for one particular TTL value, 13 | # because some networks have multiple equally weighted routes. 14 | # 15 | class ProbeRecord: 16 | def __init__(self, ttl): 17 | self.ttl = ttl 18 | self.success = False 19 | self.ip_addrs = [] 20 | self.probe_times = [] 21 | 22 | # Format the information about this line for display 23 | def print(self, screen): 24 | line = '{:>2}. '.format(self.ttl) 25 | 26 | if self.ip_addrs: 27 | line += '{:42}'.format(self.ip_addrs[0]) 28 | else: 29 | line += '{:42}'.format(' ???') 30 | 31 | for time in self.probe_times: 32 | if time is None: 33 | line += ' *' 34 | else: 35 | line += ' {:>7.3f}ms'.format(time) 36 | 37 | # Use curses to display the line 38 | screen.addstr(line + '\n') 39 | 40 | # List IP addresses beyond the first 41 | for addr in self.ip_addrs[1:]: 42 | screen.addstr(' ' + addr + '\n') 43 | 44 | 45 | # When we've got a result for one of our probes, we'll regenerate 46 | # the screen output, and allow curses to refresh it. 47 | def redraw(screen, hostname, all_records): 48 | screen.erase() 49 | 50 | screen.addstr('Tracing to "{}"\n\n'.format(hostname)) 51 | 52 | for record in all_records: 53 | record.print(screen) 54 | 55 | # If one of our probes has arrived at the destination IP, 56 | # we don't need to display further hops 57 | if record.success: 58 | break 59 | 60 | screen.addstr('\n(press SPACEBAR to exit)\n') 61 | 62 | screen.refresh() 63 | 64 | 65 | # Perform multiple probes with a specific time to live (TTL) value 66 | async def probe_ttl(mtr, hostname, ttl, record, redraw_callback): 67 | for count in range(3): 68 | result = await mtr.probe(hostname, ttl=ttl, timeout=1) 69 | 70 | if result.success: 71 | record.success = True 72 | 73 | # Record the time of the latest probe 74 | record.probe_times.append(result.time_ms) 75 | 76 | addr = result.responder 77 | # If the address of the responder isn't already in the list 78 | # of addresses responding at this TTL, add it 79 | if addr and addr not in record.ip_addrs: 80 | record.ip_addrs.append(addr) 81 | 82 | # Redraw the display, which will include this probe 83 | redraw_callback() 84 | 85 | # Wait a small amount of time before sending the next probe 86 | # to get an independent sample of network conditions 87 | await asyncio.sleep(0.1) 88 | 89 | 90 | # Launch all the probes for the trace. 91 | # We'll use a separate coroutine (probe_ttl) for each ttl value, 92 | # and those coroutines will run concurrently. 93 | async def launch_probes(screen, hostname): 94 | all_records = [] 95 | 96 | # When one of the probes has a result to display, we'll use 97 | # this callback to display it 98 | def redraw_hops(): 99 | redraw(screen, hostname, all_records) 100 | 101 | async with mtrpacket.MtrPacket() as mtr: 102 | probe_tasks = [] 103 | 104 | try: 105 | for ttl in range(1, 32): 106 | # We need a new ProbeRecord for each ttl value 107 | record = ProbeRecord(ttl) 108 | all_records.append(record) 109 | 110 | # Start a new asyncio task for this probe 111 | probe_coro = probe_ttl(mtr, hostname, ttl, record, redraw_hops) 112 | probe_tasks.append(asyncio.ensure_future(probe_coro)) 113 | 114 | # Give each probe a slight delay to avoid flooding 115 | # the network interface, which might perturb the 116 | # results 117 | await asyncio.sleep(0.05) 118 | 119 | await asyncio.gather(*probe_tasks) 120 | finally: 121 | # We may have been cancelled, so we should cancel 122 | # the probe tasks we started to clean up 123 | for task in probe_tasks: 124 | task.cancel() 125 | 126 | 127 | # Wait until a SPACE character to be read on stdin. 128 | # Afterward, cancel the probe task so we can exit 129 | async def wait_for_spacebar(probe_task): 130 | exit_event = asyncio.Event() 131 | 132 | def read_callback(): 133 | # Read a single character 134 | # If we tried to read more, we may block other tasks 135 | key = sys.stdin.read(1) 136 | if key == ' ': 137 | exit_event.set() 138 | 139 | loop = asyncio.get_event_loop() 140 | loop.add_reader(sys.stdin, read_callback) 141 | await exit_event.wait() 142 | loop.remove_reader(sys.stdin) 143 | 144 | # After spacebar is pressed, stop sending probes 145 | probe_task.cancel() 146 | 147 | 148 | # The main asynchronous routine, running within the asyncio event loop 149 | async def main_task(hostname): 150 | screen = curses.initscr() 151 | try: 152 | probe_task = asyncio.ensure_future(launch_probes(screen, hostname)) 153 | spacebar_task = asyncio.ensure_future(wait_for_spacebar(probe_task)) 154 | 155 | try: 156 | await asyncio.gather(probe_task, spacebar_task) 157 | except asyncio.CancelledError: 158 | # It is normal for probe_task to be cancelled by 159 | # the spacebar task 160 | pass 161 | finally: 162 | # We need to clean up by cancelling if gather has returned 163 | # early, perhaps due to an exception raised in one of 164 | # our tasks. 165 | probe_task.cancel() 166 | spacebar_task.cancel() 167 | finally: 168 | curses.endwin() 169 | 170 | 171 | # Use asyncio's event loop for the main body of our program. 172 | # We want to use curses for displaying results throughout our 173 | # execution, so we'll wrap the event loop with curses initialization. 174 | def main(hostname): 175 | loop = asyncio.get_event_loop() 176 | try: 177 | loop.run_until_complete(main_task(hostname)) 178 | except mtrpacket.HostResolveError: 179 | print("Can't resolve host '{}'".format(hostname)) 180 | finally: 181 | loop.close() 182 | 183 | 184 | # Get the hostname to trace to on the commandline 185 | if len(sys.argv) > 1: 186 | main(sys.argv[1]) 187 | else: 188 | print('Usage: python3 trace-concurrent.py ') 189 | sys.exit(1) 190 | -------------------------------------------------------------------------------- /examples/trace-sequential.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import mtrpacket 4 | 5 | 6 | # 7 | # Coroutine which sends probes for each network hop. 8 | # In this case, we wait for each probe to complete before 9 | # sending the next, but a more complicated program could 10 | # issue multiple probes concurrently. 11 | # 12 | async def trace(host): 13 | async with mtrpacket.MtrPacket() as mtr: 14 | 15 | # 16 | # The time-to-live (TTL) value of the probe determines 17 | # the number of network hops the probe will take 18 | # before its status is reported 19 | # 20 | for ttl in range(1, 256): 21 | result = await mtr.probe(host, ttl=ttl) 22 | 23 | # Format the probe results for printing 24 | line = '{}.'.format(ttl) 25 | if result.time_ms: 26 | line += ' {}ms'.format(result.time_ms) 27 | line += ' {}'.format(result.result) 28 | if result.responder: 29 | line += ' from {}'.format(result.responder) 30 | 31 | print(line) 32 | 33 | # If a probe arrived at its destination IP address, 34 | # there is no need for further probing. 35 | if result.success: 36 | break 37 | 38 | 39 | # Get a hostname to trace to on the commandline 40 | if len(sys.argv) > 1: 41 | hostname = sys.argv[1] 42 | else: 43 | print('Usage: python3 trace-sequential.py ') 44 | sys.exit(1) 45 | 46 | 47 | # We need asyncio's event loop to run the coroutine 48 | loop = asyncio.get_event_loop() 49 | try: 50 | trace_coroutine = trace(hostname) 51 | try: 52 | loop.run_until_complete(trace_coroutine) 53 | except mtrpacket.HostResolveError: 54 | print("Can't resolve host '{}'".format(hostname)) 55 | finally: 56 | loop.close() 57 | -------------------------------------------------------------------------------- /mtrpacket/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # mtrpacket - Asynchronous network probes for Python 3 | # Copyright (c) 2019 Matt Kimball 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to 7 | # deal in the Software without restriction, including without limitation the 8 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | # sell copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | # DEALINGS IN THE SOFTWARE. 22 | # 23 | 24 | 25 | """Asynchronous network probes for Python 26 | 27 | mtrpacket is a package for sending IPv4 and IPv6 network probes ('pings') 28 | asynchronously from Python programs. Python's asyncio library 29 | provides the event loop and mechanism for incorporating mtrpacket's 30 | network probes with other concurrent operations. 31 | 32 | mtrpacket supports a variety of probe customization options. 33 | Time-to-live (TTL) may be explicitly used for traceroute-like 34 | functionality. Probes can be sent using a variety of protocols: 35 | ICMP, UDP, TCP and SCTP. UDP, TCP and SCTP probes may be sent 36 | with specific source and destination ports. Probes can be sent 37 | with a particular packet size and payload bit-pattern. 38 | On Linux, probes can be sent with a routing "mark". 39 | 40 | mtrpacket works on Linux, MacOS, Windows (with Cygwin) and 41 | various Unix systems. Requirements are Python (>= 3.5) and 42 | mtr (>= 0.88). mtr is distributed with many Linux distributions -- 43 | you may have it installed already. For other operating systems, 44 | see https://github.com/traviscross/mtr 45 | 46 | ## Getting started 47 | 48 | The easiest way to get started with mtrpacket is to use `async with` 49 | to open an mtrpacket session, and then call probe on that session. 50 | This must be done in an asyncio coroutine. asyncio manages the event loop. 51 | 52 | For example: 53 | 54 | ``` 55 | import asyncio 56 | import mtrpacket 57 | 58 | # A simple coroutine which will start an mtrpacket session and 59 | # ping localhost 60 | async def probe(): 61 | async with mtrpacket.MtrPacket() as mtr: 62 | return await mtr.probe('localhost') 63 | 64 | # Use asyncio's event loop to start the coroutine and wait for the probe 65 | loop = asyncio.get_event_loop() 66 | try: 67 | result = loop.run_until_complete(probe()) 68 | finally: 69 | loop.close() 70 | 71 | # Print the probe result 72 | print(result) 73 | ``` 74 | 75 | Keyword arguments may be used with `mtr.probe` to further customize 76 | the network probe. For example: 77 | 78 | ``` 79 | # Send a probe to the HTTPS port of example.com and limit the probe 80 | # to four network hops 81 | result = await mtr.probe( 82 | 'example.com', 83 | ttl=4, 84 | protocol='tcp', 85 | port=443) 86 | ``` 87 | """ 88 | 89 | 90 | import asyncio 91 | import os 92 | import socket 93 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple 94 | 95 | 96 | # 97 | # We resolve a hostname to an IP address in order to send a probe. 98 | # Because the DNS resolution can have a significant impact on 99 | # performance when there are hundreds of simultaneous probes 100 | # in-flight, we will cache IP addresses for hostnames for 101 | # an MtrPacket session. 102 | # 103 | DnsCacheType = Dict[Tuple[str, Optional[int]], Tuple[str, int]] 104 | 105 | 106 | class MtrPacket: 107 | 108 | """The mtr-packet subprocess which can send network probes 109 | 110 | MtrPacket opens a subprocess to an external 'mtr-packet' program, 111 | and request that the subprocess send network probes. Multiple 112 | probe requests can be simultaneously in-transit, with results 113 | processed asynchronously, as they arrive. 114 | 115 | If an argument is provided to the constructor, it will be used 116 | as the shell command to invoke the 'mtr-packet' subprocess. 117 | If no such argument is provided, the contents of the `MTR_PACKET` 118 | environment variable will be used, if set. Otherwise, the 119 | default command will be 'mtr-packet'. 120 | """ 121 | 122 | def __init__(self, mtr_packet_command=None): 123 | self.process = None 124 | 125 | self._opened = False 126 | self._command_futures = {} 127 | self._result_task = None 128 | self._next_command_token = 1 129 | self._dns_cache = {} 130 | 131 | if not mtr_packet_command: 132 | mtr_packet_command = os.environ.get('MTR_PACKET') 133 | if not mtr_packet_command: 134 | mtr_packet_command = 'mtr-packet' 135 | self._subprocess_command = mtr_packet_command 136 | 137 | def __repr__(self): 138 | rep = ' None: 159 | 160 | """Raise an anception in all command futures 161 | 162 | If the dispatch tasks terminates before all command futures 163 | have completed, because the subprocess terminated, 164 | because of an unexpected exception, or because the task 165 | was cancelled, we need to complete all command futures 166 | with an exception.""" 167 | 168 | for future in self._command_futures.values(): 169 | # the Future may have already been cancelled 170 | if not future.done(): 171 | future.set_exception(exception) 172 | 173 | self._command_futures.clear() 174 | 175 | async def _dispatch_results(self) -> None: 176 | 177 | """Task which handles results printed to the stdout of mtr-packet 178 | 179 | Each time the result of a command is printed to stdout of the 180 | mtr-packet subprocess, dispatch that result to the future waiting 181 | on command completion. 182 | 183 | If the stdout of the subprocess is closed, complete all outstanding 184 | futures with an exception. This is needed if the process is killed 185 | unexpectedly. 186 | """ 187 | 188 | try: 189 | while not self.process.stdout.at_eof(): 190 | line = await self.process.stdout.readline() 191 | self._dispatch_result_line(line.decode('ascii')) 192 | finally: 193 | exc_description = \ 194 | 'failure to communicate with subprocess "{}"'.format( 195 | self._subprocess_command) 196 | exc_description += " (is it installed and in the PATH?)" 197 | exception = ProcessError(exc_description) 198 | 199 | self._raise_exception_in_command_futures(exception) 200 | 201 | def _generate_command_token(self) -> str: 202 | 203 | """Return a command token usable for a new command 204 | 205 | Command tokens are unique 32-bit integers associated with 206 | command requests which allow the command result to be matched 207 | with the request, even with results arriving out of order. 208 | """ 209 | 210 | token = str(self._next_command_token) 211 | if self._next_command_token < 0x7FFFFFFF: 212 | self._next_command_token += 1 213 | else: 214 | self._next_command_token = 1 215 | 216 | assert token not in self._command_futures 217 | return token 218 | 219 | def _dispatch_result_line(self, line: str) -> None: 220 | 221 | """Given a command result in string form, dispatch to originator 222 | 223 | This is called with a command string read from the stdout of 224 | the mtr-packet subprocess. We will package the result arguments 225 | into a dictionary, and then dispatch the result and arguments 226 | to the future associated with the request. 227 | """ 228 | 229 | atoms = line.strip().split(' ') 230 | if len(atoms) < 2: 231 | return 232 | 233 | token = atoms[0] 234 | result = atoms[1] 235 | 236 | arguments = {} 237 | argument_index = 2 238 | while argument_index + 1 < len(atoms): 239 | argument_name = atoms[argument_index] 240 | argument_value = atoms[argument_index + 1] 241 | arguments[argument_name] = argument_value 242 | 243 | argument_index += 2 244 | 245 | result_tuple = (result, arguments) 246 | future = self._command_futures.get(token) 247 | if future: 248 | del self._command_futures[token] 249 | 250 | # if the command task is canceled, the future may be done 251 | if not future.done(): 252 | future.set_result(result_tuple) 253 | 254 | async def _command( 255 | self, 256 | command_type: str, 257 | arguments: Dict[str, str]) -> Tuple[str, Dict[str, str]]: 258 | 259 | """Send a command with arguments to the mtr-packet subprocess 260 | 261 | Assign a command token to a new command request. Construct 262 | a string with the command and all arguments, sending it to 263 | stdin of the subprocess. Wait on a future to be completed 264 | when the result of the command is available. 265 | """ 266 | 267 | if not self._opened: 268 | raise StateError('not open') 269 | 270 | if self._result_task.done(): 271 | exc_description = 'subprocess "{}" exited'.format( 272 | self._subprocess_command) 273 | raise ProcessError(exc_description) 274 | 275 | token = self._generate_command_token() 276 | future = asyncio.get_event_loop().create_future() 277 | self._command_futures[token] = future 278 | 279 | command_str = token + ' ' + command_type 280 | for argument_name in arguments: 281 | argument_value = arguments[argument_name] 282 | command_str += ' ' + argument_name + ' ' + argument_value 283 | command_str += '\n' 284 | 285 | self.process.stdin.write(command_str.encode('ascii')) 286 | return await future 287 | 288 | async def open(self) -> 'MtrPacket': 289 | 290 | """Launch an mtr-packet subprocess to accept commands 291 | (asynchronous) 292 | 293 | If a command argument was passed to the constructor of the 294 | `MtrPacket` object, that command will be used to launch the 295 | subprocess. 296 | 297 | As an alternative to calling open() explicitly, an 'async with' 298 | block can be used with the MtrPacket object to open and close the 299 | subprocess. 300 | 301 | Raises ProcessError if the subprocess fails to execute or 302 | if the subprocess doesn't support sending packets. 303 | 304 | Raises StateError if the subprocess is already open. 305 | """ 306 | 307 | if self._opened: 308 | raise StateError('already open') 309 | 310 | self.process = await asyncio.create_subprocess_shell( 311 | self._subprocess_command, 312 | stdin=asyncio.subprocess.PIPE, 313 | stdout=asyncio.subprocess.PIPE) 314 | 315 | self._result_task = asyncio.ensure_future(self._dispatch_results()) 316 | self._opened = True 317 | 318 | if not await self.check_support('send-probe'): 319 | await self.close() 320 | 321 | raise ProcessError('subprocess missing probe support') 322 | 323 | return self 324 | 325 | async def close(self) -> None: 326 | 327 | """Close an open mtr-packet subprocess 328 | (asynchronous) 329 | 330 | If open() was explicitly called to launch the mtr-packet 331 | subprocess, close() should be used to terminate the process 332 | and clean up resources. 333 | """ 334 | 335 | self._opened = False 336 | 337 | if self._result_task: 338 | self._result_task.cancel() 339 | self._result_task = None 340 | 341 | if self.process: 342 | self.process.stdin.close() 343 | 344 | try: 345 | self.process.kill() 346 | except ProcessLookupError: 347 | pass 348 | 349 | await self.process.wait() 350 | self.process = None 351 | 352 | async def check_support(self, feature: str) -> bool: 353 | 354 | """Check for support of a particular feature of mtr-packet 355 | (asynchronous) 356 | 357 | check_support() can be used to check support for particular 358 | features in the mtr-packet subprocess. For example, 'udp', 359 | 'tcp' and 'sctp' can be used to check support for UDP probes, 360 | TCP probes, and SCTP probes. 361 | 362 | See 'check-support' in the mtr-packet(8) man page for more 363 | information. 364 | 365 | Raises ProcessError if the mtr-packet subprocess has unexpectedly 366 | terminated. 367 | 368 | Raises StateError if the MtrPacket session hasn't been opened. 369 | """ 370 | 371 | check_args = {'feature': feature} 372 | (_, args) = await self._command('check-support', check_args) 373 | 374 | return args.get('support') == 'ok' 375 | 376 | async def probe(self, host: str, **args) -> 'ProbeResult': 377 | 378 | """Asynchronously send a network probe 379 | (asynchronous) 380 | 381 | Send a network probe to a particular hostname or IP address, 382 | returning a ProbeResult, which includes the status of the probe, 383 | the address of the host responding to the probe and the round trip 384 | time of the probe. 385 | 386 | A number of optional keyword arguments can be used with the 387 | probe request: 388 | 389 | 390 | ip_version: 391 | Set the IP protocol version to either IPv4 or IPv6. 392 | 393 | ttl: 394 | Set the "time to live" of the probe request. This is used 395 | to limit the number of network hops the probe takes before 396 | the probe result is reported. 397 | 398 | protocol: 399 | Can be 'icmp', 'udp', 'tcp' or 'sctp'. A probe of the requested 400 | protocol is used. 401 | 402 | port: 403 | The destination port to use for 'udp', 'tcp' or 'sctp' probes. 404 | 405 | local_ip: 406 | Set the source address of the probe to a particular 407 | IP address. Useful when sending from a host with multiple 408 | IP local addresses. 409 | 410 | local_port: 411 | Send the probe from a particular port, when sending 'udp', 412 | 'tcp' or 'sctp' probes. 413 | 414 | timeout: 415 | The number of seconds to wait for a response before assuming 416 | the probe has been lost. 417 | 418 | size: 419 | The size of the generated probe packet, in bytes. 420 | 421 | bit_pattern: 422 | A byte value used to fill the payload of the probe packet. 423 | 424 | tos: 425 | The value to use in the "type of service" field for IPv4 426 | packets, or the "traffic class" field of IPv6 packets. 427 | 428 | mark: 429 | The packet "mark" value to be used by the Linux routing 430 | subsystem. 431 | 432 | 433 | Raises ProcessError if the mtr-packet subprocess has unexpectedly 434 | terminated. 435 | 436 | Raises HostResolveError if the hostname can't be resolved to 437 | an IP address. 438 | 439 | Raises StateError if the MtrPacket session hasn't been opened. 440 | """ 441 | 442 | pack = await _package_args(self._dns_cache, host, args) 443 | 444 | return _make_probe_result(*await self._command('send-probe', pack)) 445 | 446 | def clear_dns_cache(self) -> None: 447 | 448 | """Clear MtrPacket's DNS cache 449 | 450 | For performance reasons, when repeatedly probing a particular 451 | host, MtrPacket will only resolve the hostname one time, and 452 | will use the same IP address for subsequent probes to 453 | the same host. 454 | 455 | clear_dns_cache can be used to clear that cache, forcing 456 | new resolution of hostnames to IP addresses for future probes. 457 | This can be useful for scripts which are intended to run 458 | for an extended period of time. (Hours, or longer) 459 | """ 460 | 461 | self._dns_cache = {} 462 | 463 | 464 | async def _resolve_ip( 465 | dns_cache: DnsCacheType, 466 | host: str, 467 | target_ip_version: Optional[int] 468 | ) -> Tuple[str, int]: 469 | 470 | """Asynchronously resolve a hostname to an IP address 471 | 472 | Resolve a hostname prior to sending a network probe. An optional 473 | IP version parameter can be used to require either an IPv4 or 474 | IPv6 address. 475 | """ 476 | 477 | cache_key = (host, target_ip_version) 478 | if cache_key in dns_cache: 479 | return dns_cache[cache_key] 480 | 481 | try: 482 | addrinfo = await asyncio.get_event_loop().getaddrinfo(host, 0) 483 | except socket.gaierror: 484 | raise HostResolveError("Unable to resolve '{}'".format(host)) 485 | 486 | for info in addrinfo: 487 | (family, _, _, _, addr) = info 488 | 489 | if family == socket.AF_INET: 490 | if not target_ip_version or target_ip_version == 4: 491 | dns_addr = (addr[0], 4) 492 | dns_cache[cache_key] = dns_addr 493 | return dns_addr 494 | 495 | if family == socket.AF_INET6: 496 | if not target_ip_version or target_ip_version == 6: 497 | dns_addr = (addr[0], 6) 498 | dns_cache[cache_key] = dns_addr 499 | return dns_addr 500 | 501 | raise HostResolveError("Unable to resolve '{}'".format(host)) 502 | 503 | 504 | async def _package_args( 505 | dns_cache: DnsCacheType, 506 | host: str, 507 | args: Dict[str, Any] 508 | ) -> Dict[str, str]: 509 | 510 | """Package the arguments from a call to MtrPacket.probe 511 | 512 | In preparation for sending a command to the mtr-packet subprocess, 513 | package the arguments from MtrPacket.probe into the format 514 | expected by the mtr-packet subprocess. Resolve hostnames to 515 | IP addresses, prefering either IPv4 or IPv6 as specified by 516 | the ip_version argument. 517 | """ 518 | 519 | host_ip = None 520 | host_ip_version = None 521 | target_ip_version = args.get('ip_version') 522 | if target_ip_version and target_ip_version not in (4, 6): 523 | raise ValueError('expected ip_version to be either 4 or 6') 524 | 525 | pack = {} 526 | 527 | (host_ip, host_ip_version) = await _resolve_ip( 528 | dns_cache, host, target_ip_version) 529 | 530 | if host_ip_version == 4: 531 | pack['ip-4'] = host_ip 532 | elif host_ip_version == 6: 533 | pack['ip-6'] = host_ip 534 | 535 | validargs = [ 536 | 'protocol', 'port', 'local-port', 'timeout', 'ttl', 'size', 537 | 'bit-pattern', 'tos', 'mark' 538 | ] 539 | 540 | for argname in args: 541 | keyname = argname.replace('_', '-') 542 | 543 | if argname == 'local_ip': 544 | (local_ip, _) = await _resolve_ip( 545 | dns_cache, args[argname], host_ip_version) 546 | 547 | if host_ip_version == 4: 548 | pack['local-ip-4'] = local_ip 549 | elif host_ip_version == 6: 550 | pack['local-ip-6'] = local_ip 551 | elif argname == 'ip_version': 552 | pass # We've handled 'ip_version' above. 553 | elif keyname in validargs: 554 | pack[keyname] = str(args[argname]) 555 | else: 556 | raise TypeError( 557 | "unexpected keyword argument '{}'".format(keyname)) 558 | 559 | return pack 560 | 561 | 562 | # 563 | # A named tuple describing the result of a network probe 564 | # 565 | # A call to MtrPacket.probe will result in an instance of 566 | # ProbeResult with the following members: 567 | # 568 | # success: 569 | # a bool which is True only if the probe arrived at the target 570 | # host. 571 | # 572 | # result: 573 | # the command reply string from mtr-packet. Common values 574 | # are 'reply' for a probe which arrives at the target host, 575 | # 'ttl-expired' for a probe which has its "time to live" 576 | # counter reach zero before arriving at the target host, 577 | # and 'no-reply' for a probe which is unanswered. 578 | # 579 | # See the mtr-packet(8) man page for further command reply 580 | # strings. 581 | # 582 | # time_ms: 583 | # a floating point value indicating the number of milliseconds 584 | # the probe was in-transit, prior to receiving a result. 585 | # Will be None in cases other than 'reply' or 'ttl-expired'. 586 | # 587 | # responder: 588 | # a string with the IP address of the host responding to the 589 | # probe. Will be None in cases other than 'reply' or 'ttl-expired'. 590 | # 591 | # mpls: 592 | # a list of Mpls tuples representing the MPLS label stack present in 593 | # a 'ttl-expired' response, when Multiprotocol Label Switching (MPLS) 594 | # is used to route the probe. 595 | # 596 | ProbeResult = NamedTuple('ProbeResult', [ 597 | ('success', bool), 598 | ('result', str), 599 | ('time_ms', Optional[float]), 600 | ('responder', Optional[str]), 601 | ('mpls', List['Mpls']) 602 | ]) 603 | 604 | 605 | def _make_probe_result( 606 | command_result: str, args: Dict[str, str]) -> ProbeResult: 607 | 608 | """Construct a ProbeResult from the output of mtr-packet 609 | 610 | Given the command response strings from the mtr-packet subprocess, 611 | construct a ProbeResult NamedTuple, suitable for returning 612 | from a call to MtrPacket.probe. 613 | """ 614 | 615 | success = (command_result == 'reply') 616 | responder = args.get('ip-4') or args.get('ip-6') 617 | 618 | time_us = args.get('round-trip-time') 619 | if time_us: 620 | time_ms = float(time_us) / 1000.0 # type: Optional[float] 621 | else: 622 | time_ms = None 623 | 624 | # The MPLS values are a sequence of comma separated integers 625 | mpls = [] 626 | mpls_arg = args.get('mpls') 627 | if mpls_arg: 628 | mpls_values = list(map(int, mpls_arg.split(','))) 629 | 630 | while len(mpls_values) >= 4: 631 | mpls.append(Mpls( 632 | mpls_values[0], 633 | mpls_values[1], 634 | bool(mpls_values[2]), 635 | mpls_values[3])) 636 | 637 | mpls_values = mpls_values[4:] 638 | 639 | return ProbeResult(success, command_result, time_ms, responder, mpls) 640 | 641 | 642 | # 643 | # A named tuple describing an MPLS header. 644 | # 645 | # Multiprotocol Label Switching (MPLS) routes packet using explicit 646 | # headers attach to the packet, rather than using the IP address 647 | # for routing. When a probe's time-to-live (TTL) expires, and MPLS is 648 | # used at the router where the expiration occurs, the MPLS headers 649 | # attached to the packet may be returned with the TTL expiration 650 | # notification. 651 | # 652 | # Mpls contains one of those headers, with: 653 | # 654 | # label: 655 | # the numeric MPLS label. 656 | # 657 | # traffic_class: 658 | # the traffic class (for quality of service). 659 | # This field was formerly known as "experimental use". 660 | # 661 | # bottom_of_stack: 662 | # a boolean indicating whether the label terminates the stack. 663 | # 664 | # ttl: 665 | # the time-to-live of the MPLS header 666 | # 667 | Mpls = NamedTuple('Mpls', [ 668 | ('label', int), 669 | ('traffic_class', int), 670 | ('bottom_of_stack', bool), 671 | ('ttl', int) 672 | ]) 673 | 674 | 675 | class StateError(Exception): 676 | 677 | """Exception raised when attempting to use MtrPacket in an invalid state 678 | 679 | StateError is raised when attempting to send a command to the mtr-packet 680 | subprocess without first opening the MtrPacket subprocess, or when 681 | attempting to open a subprocess which is already open. 682 | """ 683 | 684 | 685 | class HostResolveError(Exception): 686 | 687 | """Exception raised when attempting to probe a non-resolving hostname 688 | 689 | If a hostname is passed to MtrPacket.probe, and that hostname fails 690 | to resolve to an IP address, HostResolveError is raised. 691 | """ 692 | 693 | 694 | class ProcessError(Exception): 695 | 696 | """Exception raised when the mtr-packet subprocess unexpectedly exits 697 | 698 | ProcessError is raised by a call to MtrPacket.probe 699 | or MtrPacket.check_support when the mtr-packet subprocess has 700 | unexpectly terminated. It is also raised by MtrPacket.open when 701 | the subprocess doesn't support the mtr-packet interface. 702 | """ 703 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="mtrpacket", 8 | version="1.0.1", 9 | python_requires=">=3.5", 10 | author="Matt Kimball", 11 | author_email="matt.kimball@gmail.com", 12 | description="Asynchronous network probes for Python", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/matt-kimball/mtr-packet-python", 16 | packages=setuptools.find_packages(), 17 | classifiers=[ 18 | "Topic :: System :: Networking", 19 | "Framework :: AsyncIO", 20 | "Programming Language :: Python :: 3", 21 | "License :: OSI Approved :: MIT License", 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd test 4 | PYTHONPATH=.. python3 -X dev test.py 5 | -------------------------------------------------------------------------------- /test/nc_mock.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | nc localhost 8901 4 | -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import os 4 | import unittest 5 | 6 | import mtrpacket 7 | 8 | 9 | Command = collections.namedtuple('Command', ['token', 'command', 'args']) 10 | 11 | 12 | def make_command(line): 13 | 14 | """Convert a command string into a Command named type""" 15 | 16 | atoms = line.decode('ascii').strip().split(' ') 17 | 18 | token = atoms[0] 19 | command = atoms[1] 20 | args = {} 21 | 22 | i = 2 23 | while i + 1 < len(atoms): 24 | args[atoms[i]] = atoms[i + 1] 25 | i += 2 26 | 27 | return Command(token, command, args) 28 | 29 | 30 | class MtrPacketSubstitute: 31 | 32 | """Substitute the 'mtr-packet' executable with an alternative 33 | 34 | To be used in a 'with' code block to provide an altnerative 35 | to 'mtr-packet' for mtrpacket.MtrPacket objects used in that block. 36 | """ 37 | 38 | def __init__(self, substitute): 39 | self.substitute = substitute 40 | self.saved = None 41 | 42 | def __enter__(self): 43 | try: 44 | self.saved = os.environ['MTR_PACKET'] 45 | except KeyError: 46 | self.saved = None 47 | 48 | os.environ['MTR_PACKET'] = self.substitute 49 | 50 | def __exit__(self, etype, evalue, traceback): 51 | if self.saved: 52 | os.environ['MTR_PACKET'] = self.saved 53 | else: 54 | del os.environ['MTR_PACKET'] 55 | 56 | 57 | class TestProcExit(unittest.TestCase): 58 | 59 | """Test behavior when the mtr-packet subprocess unexpectedly exits""" 60 | 61 | async def async_proc_exit(self): 62 | async with mtrpacket.MtrPacket() as mtr: 63 | await mtr.probe('127.0.0.1') 64 | 65 | def test_proc_exit(self): 66 | with MtrPacketSubstitute('true'): 67 | with self.assertRaises(mtrpacket.ProcessError): 68 | asyncio_run(self.async_proc_exit()) 69 | 70 | 71 | class TestMissingExecutable(unittest.TestCase): 72 | 73 | """Test behavior when 'mtr-packet' is missing""" 74 | 75 | async def async_missing_exec(self): 76 | async with mtrpacket.MtrPacket() as mtr: 77 | pass 78 | 79 | def test_missing_exec(self): 80 | with MtrPacketSubstitute('mtr-packet-missing'): 81 | with self.assertRaises(mtrpacket.ProcessError): 82 | asyncio_run(self.async_missing_exec()) 83 | 84 | 85 | class TestCancellation(unittest.TestCase): 86 | 87 | """Test whether a task waiting on a probe can be cancelled 88 | 89 | There was a problem where cancelling a task waiting on a 90 | probe caused exceptions within the cancellation due to a 91 | cancelled future being completed with an exception. 92 | 93 | Test for that. 94 | """ 95 | 96 | async def command_wait(self, mtr): 97 | await mtr.probe('127.255.255.255', timeout=60) 98 | 99 | async def async_launch(self): 100 | async with mtrpacket.MtrPacket() as mtr: 101 | coro = self.command_wait(mtr) 102 | task = asyncio.ensure_future(coro) 103 | await asyncio.sleep(1) 104 | task.cancel() 105 | try: 106 | await task 107 | except asyncio.CancelledError: 108 | pass 109 | 110 | 111 | def test_cancel(self): 112 | asyncio_run(self.async_launch()) 113 | 114 | 115 | class TestCommands(unittest.TestCase): 116 | 117 | """Bind a socket as a substitute for mtr-packet and test commands passed 118 | 119 | We will substitute 'nc' for 'mtr-packet' and connect to a socket in this 120 | process, which allows us to get the commands issued to and generate 121 | responses as if we were the 'mtr-packet' subprocess. 122 | """ 123 | 124 | def gen_handle_command(self, in_queue, out_queue): 125 | async def handle_command(reader, writer): 126 | while not reader.at_eof(): 127 | line = await reader.readline() 128 | 129 | if line: 130 | command = make_command(line) 131 | 132 | if command.command == 'check-support': 133 | reply = command.token + ' feature-support support ok\n' 134 | else: 135 | # Save received commands to in_queue 136 | in_queue.put_nowait(command) 137 | 138 | # Respond with a canned response from out_queue 139 | reply_body = await out_queue.get() 140 | reply = command.token + ' ' + reply_body + '\n' 141 | 142 | writer.write(reply.encode('ascii')) 143 | 144 | writer.close() 145 | 146 | return handle_command 147 | 148 | async def send_probes(self, mtr, in_queue, out_queue): 149 | out_queue.put_nowait('reply ip-4 8.8.4.4 round-trip-time 1000') 150 | result = await mtr.probe('8.8.8.8', bit_pattern=42) 151 | command = await in_queue.get() 152 | 153 | assert command.command == 'send-probe' 154 | assert command.args['ip-4'] == '8.8.8.8' 155 | assert command.args['bit-pattern'] == '42' 156 | assert result.success 157 | assert result.responder == '8.8.4.4' 158 | assert result.time_ms == 1.0 159 | 160 | out_queue.put_nowait('reply ip-4 127.0.1.1 round-trip-time 500') 161 | result = await mtr.probe('127.0.1.1', local_ip='127.0.0.1') 162 | command = await in_queue.get() 163 | 164 | assert command.command == 'send-probe' 165 | assert command.args['ip-4'] == '127.0.1.1' 166 | assert command.args['local-ip-4'] == '127.0.0.1' 167 | assert result.success 168 | assert result.responder == '127.0.1.1' 169 | assert result.time_ms == 0.5 170 | 171 | out_queue.put_nowait('no-reply') 172 | result = await mtr.probe('::1', ttl=4) 173 | command = await in_queue.get() 174 | 175 | assert command.command == 'send-probe' 176 | assert command.args['ip-6'] == '::1' 177 | assert command.args['ttl'] == '4' 178 | assert not result.success 179 | assert result.result == 'no-reply' 180 | 181 | out_queue.put_nowait('ttl-expired ip-4 8.0.0.1 mpls 1,2,0,3,4,5,1,6') 182 | result = await mtr.probe('8.8.9.9') 183 | command = await in_queue.get() 184 | 185 | assert len(result.mpls) == 2 186 | assert result.mpls[0].label == 1 187 | assert result.mpls[0].traffic_class == 2 188 | assert result.mpls[0].bottom_of_stack is False 189 | assert result.mpls[0].ttl == 3 190 | assert result.mpls[1].label == 4 191 | assert result.mpls[1].traffic_class == 5 192 | assert result.mpls[1].bottom_of_stack is True 193 | assert result.mpls[1].ttl == 6 194 | 195 | out_queue.put_nowait('reply ip-4 127.0.0.1 round-trip-time 1000') 196 | result = await mtr.probe('localhost', ip_version=4) 197 | command = await in_queue.get() 198 | 199 | assert command.command == 'send-probe' 200 | assert 'ip-4' in command.args 201 | assert result.success 202 | assert result.responder == '127.0.0.1' 203 | assert result.time_ms == 1.0 204 | 205 | out_queue.put_nowait('reply ip-6 ::1 round-trip-time 1000') 206 | result = await mtr.probe('ip6-localhost', ip_version=6) 207 | command = await in_queue.get() 208 | 209 | assert command.command == 'send-probe' 210 | assert 'ip-6' in command.args 211 | assert result.success 212 | assert result.responder == '::1' 213 | assert result.time_ms == 1.0 214 | 215 | async def async_commands(self): 216 | in_queue = asyncio.Queue() 217 | out_queue = asyncio.Queue() 218 | 219 | server = await asyncio.start_server( 220 | self.gen_handle_command(in_queue, out_queue), '127.0.0.1', 8901) 221 | 222 | try: 223 | async with mtrpacket.MtrPacket() as mtr: 224 | await self.send_probes(mtr, in_queue, out_queue) 225 | finally: 226 | server.close() 227 | 228 | def test_commands(self): 229 | with MtrPacketSubstitute('./nc_mock.sh'): 230 | asyncio_run(self.async_commands()) 231 | 232 | 233 | def asyncio_run(coro): 234 | 235 | """Equivalent to Python 3.7's asyncio.run""" 236 | 237 | loop = asyncio.get_event_loop() 238 | return loop.run_until_complete(loop.create_task(coro)) 239 | 240 | 241 | unittest.main() 242 | --------------------------------------------------------------------------------