├── MANIFEST.in ├── .gitignore ├── NOTICE ├── CONTRIBUTING.md ├── connvitals ├── __init__.py ├── config.py ├── collector.py ├── traceroute.py ├── utils.py ├── icmp.py ├── ping.py └── ports.py ├── setup.py ├── LICENSE ├── README.md └── README.rst /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # License information 2 | include LICENSE 3 | include NOTICE 4 | 5 | # README in reStructuredText format 6 | include README.rst 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Sublime project files 2 | *.sublime-* 3 | 4 | # ViM swapfiles 5 | *.swp 6 | *.swp~ 7 | 8 | # Python Byte-Code caches 9 | *.pyc 10 | __pycache__ 11 | 12 | # Setuputils build directories 13 | build 14 | dist 15 | *.egg-info 16 | 17 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | connvitals 2 | Copyright 2018 Comcast Cable Communications Management, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | 16 | This product includes software developed at Comcast (http://www.comcast.com/). 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | If you would like to contribute code to this project you can do so through 3 | GitHub by forking the repository and sending a pull request. 4 | 5 | Please ensure that your contribution does not cause the `pylint` score of the 6 | package to fall below 9.5. You can check the score with 7 | 8 | ``` 9 | pylint /path/to/connvitals/connvitals/ 10 | ``` 11 | 12 | You may safely ignore the following errors/warnings (by setting this in your 13 | `~/.pylintrc` file or specifying it on the command line, but please don't 14 | explicitly ignore these with a pylint directive in-line with the code): 15 | 16 | * C0103 17 | * C0326 18 | * C0330 19 | * C0362 20 | * C0413 21 | * E1300 22 | * R0902 23 | * R0911 24 | * W0603 25 | * W0612 26 | * W1401 27 | 28 | The rest of `pylint`'s settings should remain default except that: 29 | 30 | * Indentations should be with **tabs only, NEVER spaces**. Spaces may be used 31 | within an indentation level to align text. 32 | * Line endings should be unix/line-feed (LF)/'\n' only, **don't** include any 33 | Windows line endings (carriage-return-then-linefeed (CRLF)/'\r\n' ) 34 | * **All** files in the project **must** end with a newline (leave a blank line 35 | at the end of the file.) 36 | 37 | If there's a good reason you must catch a general `Exception`, state your case 38 | in the pull request and I'll probably allow it. 39 | 40 | Now some comcast stuff: 41 | 42 | ## CLA 43 | --------- 44 | Before Comcast merges your code into the project you must sign the [Comcast 45 | Contributor License Agreement 46 | (CLA)](https://gist.github.com/ComcastOSS/a7b8933dd8e368535378cda25c92d19a). 47 | 48 | If you haven't previously signed a Comcast CLA, you'll automatically be asked 49 | to when you open a pull request. Alternatively, we can send you a PDF that 50 | you can sign and scan back to us. Please create a new GitHub issue to request 51 | a PDF version of the CLA. 52 | -------------------------------------------------------------------------------- /connvitals/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Comcast Cable Communications Management, LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | A utility to check connection vitals with a remote host. 17 | 18 | Usage: connvitals [ -h --help ] [ -H --hops HOPS ] [ -p --pings PINGS ] [ -P --no-ping ] 19 | [ -t --trace ] [ --payload-size PAYLOAD ] [ --port-scan ] [ -j --json ] 20 | host [hosts... ] 21 | 22 | Each 'host' can be an ipv4 address, ipv6 address, or a fully-qualified domain name. 23 | 24 | Submodules: 25 | utils: Contains utility functionality such as error/warning reporting and host address parsing 26 | ping: Groups functionality related to ICMP/ICMPv6 tests 27 | traceroute: Contains a function for tracing a route to a host 28 | ports: Specifies functions for checking specific host ports for http(s) and MySQL servers 29 | 30 | """ 31 | 32 | __version__ = "4.3.2" 33 | __author__ = "Brennan Fieck" 34 | 35 | def main() -> int: 36 | """ 37 | Runs the utility with the arguments specified on sys.argv. 38 | Returns: Always 0 to indicate "Success", unless the utility terminates 39 | prematurely with a fatal error. 40 | """ 41 | 42 | from . import config 43 | 44 | config.init() 45 | 46 | # No hosts could be parsed 47 | if not config.CONFIG or not config.CONFIG.HOSTS: 48 | from . import utils 49 | utils.error("No hosts could be parsed! Exiting...", True) 50 | 51 | from . import collector 52 | 53 | collectors = [collector.Collector(host,i+1) for i,host in enumerate(config.CONFIG.HOSTS)] 54 | 55 | # Start all the collectors 56 | for collect in collectors: 57 | collect.start() 58 | 59 | # Wait for every collector to finish 60 | # Print JSON if requested 61 | for collect in collectors: 62 | _ = collect.join() 63 | collect.result = collect.recv() 64 | print(repr(collect) if collect.conf.JSON else collect) 65 | 66 | # Errors will be indicated on stdout; because we query multiple hosts, as 67 | # long as the main routine doesn't crash, we have exited successfully. 68 | return 0 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright 2018 Comcast Cable Communications Management, LLC 3 | 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | """ 17 | The setuptools-based installer for connvitals 18 | """ 19 | 20 | import os 21 | import sys 22 | 23 | # RPMs generated for fedora/rhel/centos need to have a different name 24 | # (debian/ubuntu automatically prepends python3-, but those do not) 25 | import platform 26 | from setuptools import setup 27 | 28 | here = os.path.abspath(os.path.dirname(__file__)) 29 | 30 | sys.path.append(here) 31 | import connvitals 32 | 33 | with open(os.path.join(here, 'README.rst')) as f: 34 | long_description = f.read() 35 | 36 | setup( 37 | name="connvitals", 38 | version=connvitals.__version__, 39 | description='Checks a machines connection to a specific host or list of hosts', 40 | long_description=long_description, 41 | url='https://github.com/connvitals', 42 | author='Brennan Fieck', 43 | author_email='Brennan_WilliamFieck@comcast.com', 44 | classifiers=[ 45 | 'Development Status :: 5 - Production/Stable', 46 | 'Intended Audience :: Telecommunications Industry', 47 | 'Intended Audience :: Developers', 48 | 'Intended Audience :: Information Technology', 49 | 'Topic :: Internet', 50 | 'Topic :: Internet :: WWW/HTTP', 51 | 'Topic :: Scientific/Engineering :: Information Analysis', 52 | 'Topic :: Utilities', 53 | 'License :: OSI Approved :: Apache Software License', 54 | 'Environment :: Console', 55 | 'Operating System :: OS Independent', 56 | 'Programming Language :: Python :: Implementation :: CPython', 57 | 'Programming Language :: Python :: Implementation :: PyPy', 58 | 'Programming Language :: Python :: 3 :: Only', 59 | 'Programming Language :: Python :: 3.4', 60 | 'Programming Language :: Python :: 3.5', 61 | 'Programming Language :: Python :: 3.6', 62 | 'Programming Language :: Python :: 3.7' 63 | ], 64 | keywords='network statistics connection ping traceroute port ip', 65 | packages=['connvitals'], 66 | install_requires=['setuptools', 'typing'], 67 | entry_points={ 68 | 'console_scripts': [ 69 | 'connvitals=connvitals.__init__:main', 70 | ], 71 | }, 72 | python_requires='~=3.4' 73 | ) 74 | -------------------------------------------------------------------------------- /connvitals/config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Comcast Cable Communications Management, LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | This module defines the config options for the 'connvitals' command 17 | """ 18 | 19 | import socket 20 | from . import __version__, utils 21 | 22 | # Configuration values 23 | 24 | class Config(): 25 | """ 26 | Represents a configuration. 27 | """ 28 | 29 | def __init__(self,*,HOPS = 30, 30 | JSON = False, 31 | PAYLOAD = b'The very model of a modern Major General.', 32 | TRACE = False, 33 | NOPING = False, 34 | PORTSCAN = False, 35 | NUMPINGS = 10, 36 | HOSTS = None): 37 | """ 38 | Initializes a configuration 39 | """ 40 | self.HOPS = HOPS 41 | self.JSON = JSON 42 | self.PAYLOAD = PAYLOAD 43 | self.TRACE = TRACE 44 | self.NOPING = NOPING 45 | self.PORTSCAN = PORTSCAN 46 | self.NUMPINGS = NUMPINGS 47 | self.HOSTS = HOSTS if HOSTS is not None else {} 48 | 49 | CONFIG = None 50 | 51 | def init(): 52 | """ 53 | Initializes the configuration. 54 | """ 55 | global __version__, CONFIG 56 | 57 | from argparse import ArgumentParser as Parser 58 | parser = Parser(description="A utility to check connection vitals with a remote host.", 59 | epilog="'host' can be an ipv4 or ipv6 address, or a fully-qualified domain name.") 60 | 61 | parser.add_argument("hosts", 62 | help="The host or hosts to check connection to. "\ 63 | "These can be ipv4 addresses, ipv6 addresses, fqdn's, "\ 64 | "or any combination thereof.", 65 | nargs="*") 66 | 67 | parser.add_argument("-H", "--hops", 68 | dest="hops", 69 | help="Sets max hops for route tracing (default 30).", 70 | default=30, 71 | type=int) 72 | 73 | parser.add_argument("-p", "--pings", 74 | dest="numpings", 75 | help="Sets the number of pings to use for aggregate statistics (default 10).", 76 | default=10, 77 | type=int) 78 | 79 | parser.add_argument("-P", "--no-ping", 80 | dest="noping", 81 | help="Don't run ping tests.", 82 | action="store_true") 83 | 84 | parser.add_argument("-t", "--trace", 85 | dest="trace", 86 | help="Run route tracing.", 87 | action="store_true") 88 | 89 | parser.add_argument("-s", "--port-scan", 90 | dest="portscan", 91 | help="Scan the host(s)'s ports for commonly-used services", 92 | action="store_true") 93 | 94 | parser.add_argument("--payload-size", 95 | dest="payload", 96 | help="Sets the size (in B) of ping packet payloads (default 41).", 97 | default=b'The very model of a modern Major General.', 98 | type=int) 99 | 100 | parser.add_argument("-j", "--json", 101 | dest="json", 102 | help="Outputs in machine-readable JSON (no newlines)", 103 | action="store_true") 104 | 105 | parser.add_argument("-V", "--version", 106 | action="version", 107 | version="%(prog)s "+__version__) 108 | 109 | args = parser.parse_args() 110 | 111 | # Before doing anything else, make sure we have permission to open raw sockets 112 | try: 113 | sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, proto=1) 114 | sock.close() 115 | del sock 116 | except PermissionError: 117 | from sys import argv 118 | utils.error(PermissionError("You do not have the permissions necessary to run %s" % (argv[0],))) 119 | utils.error("(Hint: try running as root, with `capsh` or with `sudo`)", True) 120 | 121 | CONFIG = Config(HOPS = args.hops, 122 | JSON = args.json, 123 | PAYLOAD = args.payload, 124 | TRACE = args.trace, 125 | NOPING = args.noping, 126 | PORTSCAN = args.portscan, 127 | NUMPINGS = args.numpings) 128 | 129 | # Parse the list of hosts and try to find valid addresses for each 130 | CONFIG.HOSTS = {} 131 | 132 | for host in args.hosts: 133 | info = utils.getaddr(host) 134 | if not info: 135 | utils.error("Unable to resolve host ( %s )" % host) 136 | else: 137 | CONFIG.HOSTS[host] = info 138 | -------------------------------------------------------------------------------- /connvitals/collector.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Comcast Cable Communications Management, LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """This module defines a single worker to collect stats from a single host""" 16 | 17 | import multiprocessing 18 | import math 19 | from . import utils, config, ping, traceroute, ports 20 | 21 | def dummy(_): 22 | pass 23 | 24 | class Collector(multiprocessing.Process): 25 | """ 26 | A threaded worker that collects stats for a single host. 27 | """ 28 | trace = None 29 | result = [utils.PingResult(-1, -1, -1, -1, 100.), 30 | utils.Trace([utils.TraceStep('*', -1)] * 10), 31 | utils.ScanResult(None, None, None)] 32 | 33 | def __init__(self, host:str, ID:int, conf:config.Config = config.CONFIG): 34 | """ 35 | Initializes the Collector, and its worker pool 36 | """ 37 | super(Collector, self).__init__() 38 | 39 | self.hostname = host 40 | self.conf = conf 41 | self.host = conf.HOSTS[host] 42 | self.name = host 43 | self.ID = ID 44 | 45 | self.pipe = multiprocessing.Pipe() 46 | 47 | def run(self): 48 | """ 49 | Called when the thread is run 50 | """ 51 | with multiprocessing.pool.ThreadPool() as pool: 52 | pscan_result, trace_result, ping_result = None, None, None 53 | if self.conf.PORTSCAN: 54 | pscan_result = pool.apply_async(ports.portScan, 55 | (self.host, pool), 56 | error_callback=utils.error) 57 | if self.conf.TRACE: 58 | trace_result = pool.apply_async(traceroute.trace, 59 | (self.host, self.ID, self.conf), 60 | error_callback=utils.error) 61 | if not self.conf.NOPING: 62 | try: 63 | self.ping(pool) 64 | except (multiprocessing.TimeoutError, ValueError): 65 | self.result[0] = type(self).result[0] 66 | else: 67 | self.result[0] = None 68 | 69 | if self.conf.TRACE: 70 | try: 71 | self.result[1] = trace_result.get(self.conf.HOPS) 72 | except multiprocessing.TimeoutError: 73 | self.result[1] = type(self).result[1] 74 | else: 75 | self.result[1] = None 76 | 77 | if self.conf.PORTSCAN: 78 | try: 79 | self.result[2] = pscan_result.get(0.5) 80 | except multiprocessing.TimeoutError: 81 | self.result[2] = type(self).result[2] 82 | else: 83 | self.result[2] = None 84 | try: 85 | self.pipe[1].send(self.result) 86 | except OSError as e: 87 | utils.error(OSError("Error sending results: %s" % e)) 88 | 89 | def ping(self, pool:multiprocessing.pool.ThreadPool, pinger:ping.Pinger = None): 90 | """ 91 | Pings the host 92 | """ 93 | destroyPinger = dummy 94 | if pinger is None: 95 | pinger = ping.Pinger(self.host, bytes(self.conf.PAYLOAD)) 96 | destroyPinger = lambda x: x.sock.close() 97 | 98 | # Aggregates round-trip time for each packet in the sequence 99 | rtt, lost = [], 0 100 | 101 | # Sends, receives and parses all icmp packets asynchronously 102 | results = pool.map_async(pinger.ping, 103 | range(self.conf.NUMPINGS), 104 | error_callback=utils.error) 105 | pkts = results.get(2) 106 | 107 | for pkt in pkts: 108 | if pkt != None and pkt > 0: 109 | rtt.append(pkt*1000) 110 | else: 111 | lost += 1 112 | 113 | try: 114 | avg = sum(rtt) / len(rtt) 115 | std = 0. 116 | for item in rtt: 117 | std += (avg - item)**2 118 | std /= len(rtt) - 1 119 | std = math.sqrt(std) 120 | except ZeroDivisionError: 121 | std = 0. 122 | finally: 123 | destroyPinger(pinger) 124 | 125 | if rtt: 126 | self.result[0] = utils.PingResult(min(rtt), avg, max(rtt), std, lost/self.conf.NUMPINGS *100.0) 127 | else: 128 | self.result[0] = type(self).result[0] 129 | 130 | 131 | def __str__(self) -> str: 132 | """ 133 | Implements 'str(self)' 134 | 135 | Returns a plaintext output result 136 | """ 137 | ret = [] 138 | if self.host[0] == self.hostname: 139 | ret.append(self.hostname) 140 | else: 141 | ret.append("%s (%s)" % (self.hostname, self.host[0])) 142 | 143 | pings, trace, scans = self.result 144 | 145 | if pings: 146 | ret.append(str(pings)) 147 | if trace and trace != self.trace: 148 | self.trace = trace 149 | # Dirty hack because I can't inherit with strong typing in Python 3.4 150 | ret.append(utils.traceToStr(trace)) 151 | if scans: 152 | ret.append(str(scans)) 153 | 154 | return "\n".join(ret) 155 | 156 | def __repr__(self) -> repr: 157 | """ 158 | Implements `repr(self)` 159 | 160 | Returns a JSON output result 161 | """ 162 | ret = [r'{"addr":"%s"' % self.host[0]] 163 | ret.append(r'"name":"%s"' % self.hostname) 164 | 165 | if not self.conf.NOPING: 166 | ret.append(r'"ping":%s' % repr(self.result[0])) 167 | 168 | if self.conf.TRACE and self.trace != self.result[1]: 169 | self.trace = self.result[1] 170 | # Dirty hack because I can't inherit with strong typing in Python 3.4 171 | ret.append(r'"trace":%s' % utils.traceRepr(self.result[1])) 172 | 173 | if self.conf.PORTSCAN: 174 | ret.append(r'"scan":%s' % repr(self.result[2])) 175 | 176 | return ','.join(ret) + '}' 177 | 178 | 179 | def recv(self): 180 | """ 181 | Returns a message from the Collector's Pipe 182 | """ 183 | return self.pipe[0].recv() 184 | -------------------------------------------------------------------------------- /connvitals/traceroute.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Comcast Cable Communications Management, LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | This module defines a single function which implements route tracing. 17 | """ 18 | import socket 19 | import struct 20 | from struct import unpack 21 | import time 22 | from . import utils 23 | 24 | class Tracer(): 25 | """ 26 | A context-manage-able route tracer. 27 | """ 28 | 29 | def __init__(self, host:utils.Host, ID:int, maxHops:int): 30 | """ 31 | Constructs a route tracer, including the sockets used to send/recieve 32 | route tracer packets. 33 | 34 | Not that - unlike the functional implementation - this takes a direct 35 | integer instead of a whole `Config` object to determine `maxHops`. 36 | """ 37 | self.host = host 38 | self.ID = ID 39 | self.maxHops = maxHops 40 | 41 | # A bunch of stuff needs to be tweaked if we're using IPv6 42 | if host.family is socket.AF_INET6: 43 | # BTW, this doesn't actually work. The RFCs for IPv6 don't define 44 | # the behaviour of raw sockets - which are heavily utilized by 45 | # `connvitals`. One of these days, I'll have to implement it using 46 | # raw ethernet frames ... 47 | 48 | self.receiver = socket.socket(family=host.family, 49 | type=socket.SOCK_RAW, 50 | proto=socket.IPPROTO_ICMPV6) 51 | self.setTTL = self.setIPv6TTL 52 | self.isMyTraceResponse = self.isMyIPv6TraceResponse 53 | 54 | else: 55 | self.receiver = socket.socket(family=host.family, 56 | type=socket.SOCK_RAW, 57 | proto=socket.IPPROTO_ICMP) 58 | 59 | # We need a sender because a UDP socket can't receive ICMP 'TTL 60 | # Exceeded In Transit' packets, and having a raw sender introduces 61 | # a slew of new issues. 62 | self.sender = socket.socket(family=host.family, 63 | type=socket.SOCK_DGRAM) 64 | self.receiver.settimeout(0.05) 65 | 66 | def __enter__(self) -> 'Tracer': 67 | """ 68 | Context-managed instantiation. 69 | """ 70 | return self 71 | 72 | def __exit__(self, exc_type, exc_value, traceback): 73 | """ 74 | Context-managed cleanup. 75 | """ 76 | self.sender.shutdown(socket.SHUT_RDWR) 77 | self.sender.close() 78 | self.receiver.shutdown(socket.SHUT_RDWR) 79 | self.receiver.close() 80 | 81 | # Print exception information if possible 82 | if exc_type and exc_value: 83 | utils.error(exc_type("Unknown error occurred in route trace")) 84 | utils.error(exc_type(exc_value)) 85 | if traceback: 86 | utils.warn("Stack Trace for route trace error: %s" % traceback) 87 | 88 | def __del__(self): 89 | """ 90 | Non-context-managed cleanup. 91 | """ 92 | try: 93 | self.sender.close() 94 | self.receiver.close() 95 | except AttributeError: 96 | # At least one of the socket references was already deleted 97 | pass 98 | 99 | def trace(self) -> utils.Trace: 100 | """ 101 | Runs the route trace, returning a list of visited hops 102 | """ 103 | from time import time 104 | ret = [] 105 | 106 | for ttl in range(1, self.maxHops+1): 107 | self.setTTL(ttl) 108 | 109 | rtt = time() 110 | 111 | try: 112 | self.sender.sendto(b'', (self.host.addr, self.ID)) 113 | except OSError: 114 | ret.append(utils.TraceStep("*", -1)) 115 | 116 | try: 117 | while True: 118 | pkt, addr = self.receiver.recvfrom(1024) 119 | rtt = time() - rtt 120 | 121 | if self.isMyTraceResponse(pkt): 122 | break 123 | 124 | except socket.timeout: 125 | ret.append(utils.TraceStep("*", -1)) 126 | else: 127 | ret.append(utils.TraceStep(addr[0], rtt*1000)) 128 | if addr[0] == self.host.addr: 129 | break 130 | 131 | return utils.Trace(ret) 132 | 133 | def setIPv4TTL(self, ttl:int): 134 | """ 135 | Sets the TTL assuming `sender` is an IPV4 socket. 136 | """ 137 | self.sender.setsockopt(socket.SOL_IP, socket.IP_TTL, ttl) 138 | 139 | def setIPv6TTL(self, ttl:int): 140 | """ 141 | Sets the TTL assuming `sender` is an IPV6 socket. 142 | """ 143 | # Actually, hop limits should be set at the packet level, so this'll 144 | # change when I move to ethernet frames. 145 | self.sender.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_UNICAST_HOPS, ttl) 146 | 147 | def isMyIPv4TraceResponse(self, pkt:bytes) -> bool: 148 | """ 149 | Returns `True` if `pkt` is an IPv4 Traceroute Response AND it came 150 | from this particular Tracer - otherwise `False`. 151 | """ 152 | return pkt[20] in {11,3} and unpack("!H", pkt[50:52])[0] == self.ID 153 | 154 | def isMyIPv6TraceResponse(self, pkt:bytes) -> bool: 155 | """ 156 | Returns `True` if `pkt` is an IPv6 Traceroute Response AND it came 157 | from this particular Tracer - otherwise `False` 158 | """ 159 | return x[0] in {1,3} # ID fetch not implemented, since this isn't actually supported yet. 160 | 161 | # IPv4 is default 162 | setTTL = setIPv4TTL 163 | isMyTraceResponse = isMyIPv4TraceResponse 164 | 165 | 166 | # Functional implementation still exists for convenience/legacy compatibility. 167 | 168 | def trace(host: utils.Host, myID: int, config: 'config.Config') -> utils.Trace: 169 | """ 170 | Traces a route from the localhost to a given destination. 171 | Returns a tabular list of network hops up to the maximum specfied by 'hops' 172 | """ 173 | 174 | ret = [] 175 | 176 | ipv6 = host[1] == socket.AF_INET6 177 | 178 | receiver = socket.socket(family=host.family, type=socket.SOCK_RAW, proto=58 if ipv6 else 1) 179 | receiver.settimeout(0.05) 180 | sender = socket.socket(family=host.family, type=socket.SOCK_DGRAM, proto=17) 181 | 182 | # Sets up functions used in the main loop, so it can transparently 183 | # handle ipv4 and ipv6 without needing to check which one we're 184 | # using on every iteration. 185 | setTTL, isTraceResponse, getIntendedDestination = None, None, None 186 | getID = lambda x: (x[50] << 8) + x[51] 187 | if ipv6: 188 | setTTL = lambda x: sender.setsockopt(41, 4, x) 189 | isTraceResponse = lambda x: x[0] in {1, 3} 190 | getIntendedDestination = lambda x: socket.inet_ntop(socket.AF_INET6, x[32:48]) 191 | else: 192 | setTTL = lambda x: sender.setsockopt(socket.SOL_IP, socket.IP_TTL, x) 193 | isTraceResponse = lambda x: x[20] in {11, 3} 194 | getIntendedDestination = lambda x: ".".join(str(byte) for byte in x[44:48]) 195 | 196 | for ttl in range(config.HOPS): 197 | setTTL(ttl+1) 198 | timestamp = time.time() 199 | 200 | try: 201 | sender.sendto(b'', (host[0], myID)) 202 | except OSError as e: 203 | ret.append(utils.TraceStep("*", -1)) 204 | continue 205 | 206 | try: 207 | #Wait for packets sent by this trace 208 | while True: 209 | pkt, addr = receiver.recvfrom(1024) 210 | rtt = time.time() - timestamp 211 | 212 | # If this is a response from a tracer and the tracer sent 213 | # it to the same place we're sending things, then this 214 | # packet must belong to us. 215 | if isTraceResponse(pkt): 216 | destination = getIntendedDestination(pkt) 217 | if destination == host.addr and getID(pkt) == myID: 218 | break 219 | 220 | except socket.timeout: 221 | ret.append(utils.TraceStep("*", -1)), 222 | done = False 223 | else: 224 | ret.append(utils.TraceStep(addr[0], rtt*1000)) 225 | done = addr[0] == host[0] 226 | 227 | if done: 228 | break 229 | receiver.close() 230 | sender.close() 231 | return utils.Trace(ret) 232 | -------------------------------------------------------------------------------- /connvitals/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Comcast Cable Communications Management, LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | This module contains utility functions used by the main utility to do things 17 | lke printing errors and warnings to stderr, or get a single, valid IP address 18 | for a host. 19 | """ 20 | 21 | import typing 22 | import socket 23 | 24 | # I don't know why, but pylint seems to think that socket.AddressFamily isn't real, but it is. 25 | # Nobody else has this issue as far as I could find. 26 | #pylint: disable=E1101 27 | # Host = typing.NamedTuple("Host", [('addr', str), ('family', socket.AddressFamily), (name, str)]) 28 | Host = typing.NamedTuple("Host", [('addr', str), ('family', socket.AddressFamily)]) 29 | #pylint: enable=E1101 30 | 31 | PingResult = typing.NamedTuple("PingResult", [ 32 | ('minimum', float), 33 | ('avg', float), 34 | ('maximum', float), 35 | ('std', float), 36 | ('loss', float)]) 37 | 38 | def pingResultToStr(self: PingResult) -> str: 39 | """ 40 | Returns the string representation of a ping result in plaintext 41 | """ 42 | fmt = "%.3f\t%.3f\t%.3f\t%.3f\t%.3f" 43 | return fmt % (self.minimum, self.avg, self.maximum, self.std, self.loss) 44 | 45 | def pingResultRepr(self: PingResult) -> str: 46 | """ 47 | Returns the JSON representation of a ping result 48 | """ 49 | fmt = '{"min":%f,"avg":%f,"max":%f,"std":%f,"loss":%f}' 50 | return fmt % (self.minimum, self.avg, self.maximum, self.std, self.loss) 51 | 52 | PingResult.__str__ = pingResultToStr 53 | PingResult.__repr__ = pingResultRepr 54 | 55 | 56 | TraceStep = typing.NamedTuple("TraceStep", [("host", str), ("rtt", float)]) 57 | Trace = typing.NewType("Trace", typing.List[TraceStep]) 58 | 59 | def traceStepToStr(self: TraceStep) -> str: 60 | """ 61 | Returns the string representation of a step of a route trace in plaintext 62 | 63 | >>> traceStepToStr(TraceStep("1.2.3.4", 3.059267)) 64 | '1.2.3.4\\t3.059' 65 | >>> traceStepToStr(TraceStep("*", -1)) 66 | '*' 67 | """ 68 | if self.rtt < 0 or self.host == "*": 69 | return "*" 70 | return "%s\t%.3f" % (self.host, self.rtt) 71 | 72 | def traceStepRepr(self: TraceStep) -> str: 73 | """ 74 | Returns the JSON representation of a single step in a route trace 75 | 76 | >>> traceStepRepr(TraceStep("1.2.3.4", 3.059267)) 77 | '["1.2.3.4", 3.059267]' 78 | >>> traceStepRepr(TraceStep("*", -1)) 79 | '["*"]' 80 | """ 81 | if self.rtt < 0 or self.host == "*": 82 | return '["*"]' 83 | return '["%s", %f]' % (self.host, self.rtt) 84 | 85 | def compareTraceSteps(self: TraceStep, other: TraceStep) -> bool: 86 | """ 87 | Implements `self == other` 88 | 89 | Two trace steps are considered equal iff their hosts are the same - rtt is not considered. 90 | 91 | >>> compareTraceSteps(TraceStep("localhost", -800), TraceStep("localhost", 900)) 92 | True 93 | >>> compareTraceSteps(TraceStep("localhost", 7), TraceStep("127.0.0.1", 7)) 94 | False 95 | """ 96 | return self.host == other.host 97 | 98 | def traceStepIsValid(self: TraceStep) -> bool: 99 | """ 100 | Implements `bool(self)` 101 | 102 | Returns True if the step reports that the packet reached the host within the timeout, 103 | False otherwise. 104 | 105 | >>> traceStepIsValid(TraceStep('*', -1)) 106 | False 107 | >>> traceStepIsValid(TraceStep("someaddr", 0)) 108 | True 109 | >>> traceStepIsValid(TraceStep("someotheraddr", 27.0)) 110 | True 111 | """ 112 | return self.rtt >= 0 and self.host != "*" 113 | 114 | TraceStep.__str__ = traceStepToStr 115 | TraceStep.__repr__ = traceStepRepr 116 | TraceStep.__eq__ = compareTraceSteps 117 | TraceStep.__bool__ = traceStepIsValid 118 | 119 | def compareTraces(self: Trace, other: Trace) -> bool: 120 | """ 121 | Implements `self == other` 122 | 123 | Checks that traces are of the same length and contain the same hosts in the same order 124 | i.e. does *not* check the rtts of any or all trace steps. 125 | 126 | Note: ignores steps that are invalid ('*'). 127 | 128 | >>> a = Trace([TraceStep('0.0.0.1', 0), TraceStep('0.0.0.2', 0)]) 129 | >>> b=Trace([TraceStep('0.0.0.1',0), TraceStep('*',-1), TraceStep('*',-1), TraceStep('0.0.0.2',0)]) 130 | >>> compareTraces(a, b) 131 | True 132 | """ 133 | this, that = [step for step in self if step], [step for step in other if step] 134 | return len(this) == len(that) and all(this[i] == that[i] for i in range(len(this))) 135 | 136 | def traceToStr(self: Trace) -> str: 137 | """ 138 | Implements `str(self)` 139 | 140 | Returns the plaintext representation of a route trace. 141 | """ 142 | return '\n'.join(str(step) for step in self) 143 | 144 | def traceRepr(self: Trace) -> str: 145 | """ 146 | Implements `repr(self)` 147 | 148 | Returns the JSON representation of a route trace. 149 | """ 150 | return "[%s]" % ','.join(repr(step) for step in self) 151 | 152 | Trace.__str__ = traceToStr 153 | Trace.__repr__ = traceRepr 154 | Trace.__eq__ = compareTraces 155 | 156 | 157 | ScanResult = typing.NamedTuple("ScanResult", [("httpresult", typing.Tuple[float, str, str]), 158 | ("httpsresult", typing.Tuple[float, str, str]), 159 | ("mysqlresult", typing.Tuple[float, str])]) 160 | 161 | def scanResultToStr(self: ScanResult) -> str: 162 | """ 163 | Returns the string representation of a portscan result in plaintext 164 | """ 165 | return "%s\t%s\t%s" % ("%.3f, %s, %s" % self.httpresult if self.httpresult else 'None', 166 | "%.3f, %s, %s" % self.httpsresult if self.httpsresult else 'None', 167 | "%.3f, %s" % self.mysqlresult if self.mysqlresult else 'None') 168 | 169 | def scanResultRepr(self: ScanResult) -> str: 170 | """ 171 | Returns the JSON representation of a portscan result 172 | """ 173 | httpFmt = '{"rtt":%f,"response code":"%s","server":"%s"}' 174 | http = httpFmt % self.httpresult if self.httpresult else '"None"' 175 | https = httpFmt % self.httpsresult if self.httpsresult else '"None"' 176 | mySQL = '{"rtt":%f,"version":"%s"}' % self.mysqlresult if self.mysqlresult else '"None"' 177 | return '{"http":%s,"https":%s,"mysql":%s}' % (http, https, mySQL) 178 | 179 | ScanResult.__str__ = scanResultToStr 180 | ScanResult.__repr__ = scanResultRepr 181 | 182 | 183 | def error(err: Exception, fatal: int=False): 184 | """ 185 | Logs an error to stderr, then exits if fatal is a non-falsy value, using it as an exit code 186 | """ 187 | from sys import stderr 188 | from time import ctime 189 | if stderr.isatty(): 190 | fmt = "\033[38;2;255;0;0mEE: %s:" 191 | print(fmt % type(err).__name__, "%s" % err, "-\t", ctime(), "\033[m", file=stderr) 192 | else: 193 | print("EE: %s:" % type(err).__name__, "%s" % err, "-\t", ctime(), file=stderr) 194 | if fatal: 195 | exit(int(fatal)) 196 | 197 | def warn(warning: str): 198 | """ 199 | Logs a warning to stderr. 200 | """ 201 | from sys import stderr 202 | from time import ctime 203 | if stderr.isatty(): 204 | print("\033[38;2;238;216;78mWW:", warning, "-\t", ctime(), "\033[m", file=stderr) 205 | else: 206 | print("WW:", warning, '-\t', ctime(), file=stderr) 207 | 208 | def getaddr(host: str) -> typing.Optional[Host]: 209 | """ 210 | Returns a tuple of Address Family, IP Address for the host passed in `host`. 211 | """ 212 | 213 | try: 214 | addrinfo = socket.getaddrinfo(host, 1).pop() 215 | return Host(addrinfo[4][0], addrinfo[0]) 216 | except socket.gaierror: 217 | return None 218 | -------------------------------------------------------------------------------- /connvitals/icmp.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains convenience functions for dealing with the ICMP protocol. 3 | Most notably, it contains the `ICMPPkt` class, which can construct an ICMP packet object either 4 | from a simple payload and destination (which will make an 'Echo Request' of the appropriate version) 5 | or from a raw bytestring that is presumably an ICMP packet recived from an external host. 6 | """ 7 | 8 | # Copyright 2018 Comcast Cable Communications Management, LLC 9 | 10 | # Licensed under the Apache License, Version 2.0 (the "License"); 11 | # you may not use this file except in compliance with the License. 12 | # You may obtain a copy of the License at 13 | 14 | # http://www.apache.org/licenses/LICENSE-2.0 15 | 16 | # Unless required by applicable law or agreed to in writing, software 17 | # distributed under the License is distributed on an "AS IS" BASIS, 18 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | # See the License for the specific language governing permissions and 20 | # limitations under the License. 21 | 22 | import socket 23 | import struct 24 | import enum 25 | from . import utils 26 | 27 | 28 | 29 | # Gets our local IP address, for calculating ICMPv6 checksums 30 | with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s: 31 | s.connect(("2001:4998:c:1023::4", 1)) #yahoo.com public IPv6 address 32 | LADDR = socket.inet_pton(s.family, s.getsockname()[0]) 33 | 34 | LADDR = LADDR # 'LADDR' is now a global value 35 | 36 | # Note that these type code mappings only enumerate the types used by connvitals 37 | class ICMPType(enum.IntEnum): 38 | """ 39 | Mapping of ICMP type codes to their names. 40 | 41 | Meant only to be inherited for it's `str` type coercion behaviour, IPv4 and IPv6 have their own, 42 | more specific Type enumerations. 43 | """ 44 | def __str__(self) -> str: 45 | """ 46 | Gives the name of the ICMP Type. 47 | """ 48 | return self.name.replace('_', ' ') 49 | 50 | class ICMPv4Type(ICMPType): 51 | """ 52 | Mapping of ICMPv4 type codes to their names. 53 | """ 54 | Echo_Reply = 0 55 | Destination_Unreachable = 3 56 | Echo_Request = 8 57 | Time_Exceeded = 11 58 | 59 | class ICMPv6Type(ICMPType): 60 | """ 61 | Mapping of ICMPv6 type codes to their names. 62 | """ 63 | Destination_Unreachable = 1 64 | Time_Exceeded = 3 65 | Echo_Request = 128 66 | Echo_Reply = 129 67 | 68 | def ICMPv4_checksum(pkt: bytes) -> int: 69 | """ 70 | Implementation of the "Internet Checksum" specified in 71 | RFC 1071 (https://tools.ieft.org/html/rfc1071) 72 | 73 | Ideally this would act on the string as a series of half-words in host byte order, 74 | but this works. 75 | 76 | Network data is big-endian, hosts are typically little-endian, 77 | which makes this much more tedious than it needs to be. 78 | """ 79 | 80 | from sys import byteorder 81 | 82 | countTo = len(pkt) // 2 * 2 83 | total, count = 0, 0 84 | 85 | # Handle bytes in pairs (decoding as short ints) 86 | loByte, hiByte = 0, 0 87 | while count < countTo: 88 | if byteorder == "little": 89 | loByte = pkt[count] 90 | hiByte = pkt[count + 1] 91 | else: 92 | loByte = pkt[count + 1] 93 | hiByte = pkt[count] 94 | total += hiByte * 256 + loByte 95 | count += 2 96 | 97 | # Handle last byte if applicable (odd-number of bytes) 98 | # Endianness should be irrelevant in this case 99 | if countTo < len(pkt): # Check for odd length 100 | total += pkt[len(pkt) - 1] 101 | 102 | total &= 0xffffffff # Truncate sum to 32 bits (a variance from ping.c, which 103 | # uses signed ints, but overflow is unlikely in ping) 104 | 105 | total = (total >> 16) + (total & 0xffff) # Add high 16 bits to low 16 bits 106 | total += (total >> 16) # Add carry from above (if any) 107 | 108 | return socket.htons((~total) & 0xffff) 109 | 110 | def ICMPv6_checksum(pkt: bytes, laddr: bytes, raddr: bytes) -> int: 111 | """ 112 | Implementation of the ICMPv6 "Internet Checksum" as specified in 113 | RFC 1701 (https://tools.ieft.org/html/rfc1701). 114 | 115 | This takes the Payload Length from the IPv6 layer to be 32 (0x20), since we 116 | don't expect any extension headers and ICMP doesn't carry any length 117 | information. 118 | pkt: A complete ICMP packet, with the checksum field set to 0 119 | laddr: The (fully-expanded) local address of the socket that will send pkt 120 | raddr: The (fully-expanded) remote address of the host to which the pkt will be sent 121 | returns: A bytes object representing the checksum 122 | """ 123 | 124 | # IPv6 Pseudo-Header used for checksum calculation as specified by 125 | # RFC 2460 (https://tools.ieft.org/html/rfc2460) 126 | psh = laddr + raddr + struct.pack("!I", len(pkt)) + b'\x00\x00\x00:' 127 | # This last bit is the 4-byte-packed icmp6 protocol number (58 or 0xa3) 128 | 129 | 130 | total, packet = 0, psh+pkt 131 | 132 | # Sum all 2-byte words 133 | num_words = len(packet) // 2 134 | for chunk in struct.unpack("!%sH" % num_words, packet[0:num_words*2]): 135 | total += chunk 136 | 137 | # Add any left-over byte (for odd-length packets) 138 | if len(packet) % 2: 139 | total += packet[-1] << 8 140 | 141 | # Fold 32-bits into 16-bits 142 | total = (total >> 16) + (total & 0xffff) 143 | total += total >> 16 144 | return ~total + 0x10000 & 0xffff 145 | 146 | def ICMP_checksum(pkt: bytes, raddr: bytes = None) -> int: 147 | """ 148 | This is an abstraction used to allow calculation of a checksum to be 149 | agnostic of the protocol version in use. 150 | """ 151 | global LADDR 152 | return ICMPv6_checksum(pkt, LADDR, raddr) if raddr else ICMPv4_checksum(pkt) 153 | 154 | class ICMPPkt(): 155 | """ 156 | This object represents an ICMP packet, with associated convenience functions. 157 | """ 158 | 159 | fmt = "!BBH4s" 160 | version = 4 # common default 161 | 162 | def __init__(self, host:utils.Host, pkt:bytes = None, payload:bytes = None): 163 | """ 164 | Initializes an ICMP packet, either from raw bytes or a desired host and payload 165 | 166 | If constructed from a host/payload, will set the checksum and construct a 'ping' 167 | packet. 168 | """ 169 | if pkt: 170 | packetlen = len(pkt) 171 | if packetlen < 8: 172 | raise ValueError("Byte string %r too small to be ICMP packet!" % (pkt,)) 173 | elif packetlen > 8: 174 | utils.warn("ICMP packet may not be valid (has length %d, expected 8)" % (packetlen,)) 175 | pkt = pkt[:8] 176 | 177 | self.outbound = False 178 | self.Host = host 179 | Type, self.Code, self.Checksum, self.Payload = struct.unpack(self.fmt, pkt) 180 | 181 | if self.Host.family == socket.AF_INET6: 182 | self.version = 6 183 | self.Type = ICMPv6Type(Type) 184 | else: 185 | self.Type = ICMPv4Type(Type) 186 | 187 | elif payload: 188 | self.Host = host 189 | self.outbound = True 190 | self.fmt = self.fmt.replace('4', str(4+len(payload))) 191 | 192 | if self.Host[1] == socket.AF_INET6: 193 | self.version = 6 194 | self.Type = ICMPv6Type.Echo_Request 195 | else: 196 | self.Type = ICMPv4Type.Echo_Request 197 | 198 | self.Code = 0 199 | self.Payload = payload 200 | self.Checksum = self.calcChecksum() 201 | 202 | else: 203 | raise TypeError("ICMPPkt() must be called with a host, and either a packet or payload!") 204 | 205 | def calcChecksum(self) -> int: 206 | """ 207 | Calculates the checksum of this ICMP Packet 208 | """ 209 | global LADDR 210 | 211 | pkt = struct.pack(self.fmt.replace('H', "2s"), self.Type, self.Code, b'\x00\x00\x00\x00', self.Payload) 212 | 213 | if self.version == 6: 214 | 215 | hostaddr = socket.inet_pton(self.Host.family, self.Host.addr) 216 | 217 | # packet was outbound, proceed as normal 218 | if self.outbound: 219 | return ICMP_checksum(pkt, hostaddr) 220 | 221 | # packet was inbound, LADDR and raddr are reversed 222 | return ICMPv6_checksum(pkt, hostaddr, LADDR) 223 | 224 | return ICMP_checksum(pkt) 225 | 226 | 227 | def __bytes__(self) -> bytes: 228 | """ 229 | Implements `bytes(self)` 230 | 231 | This builds the packet for sending along a socket. 232 | """ 233 | return struct.pack(self.fmt, self.Type, self.Code, self.Checksum, self.Payload) 234 | 235 | def __bool__(self) -> bool: 236 | """ 237 | Implements `bool(self)` 238 | 239 | Checks that the `Checksum` attribute matches the calculated checksum. 240 | """ 241 | return self.calcChecksum() == self.Checksum 242 | 243 | def __str__(self) -> str: 244 | """ 245 | Implements `str(self)` 246 | """ 247 | return "%s ICMPv%d packet, bound for %s" % (self.Type, self.version, self.Host.addr) 248 | 249 | def __repr__(self) -> str: 250 | """ 251 | Implements `repr(self)` 252 | """ 253 | return "ICMPPkt(Type=%r, Code=%d, Payload=%r, Host=%r)" % (self.Type, self.Code, self.Payload, self.Host) 254 | 255 | @property 256 | def seqno(self) -> int: 257 | """ 258 | Gives the sequence number if this is an Echo Request/Reply packet, 259 | else raises an AttributeError. 260 | """ 261 | if self.Type not in {ICMPv6Type.Echo_Reply, 262 | ICMPv6Type.Echo_Request, 263 | ICMPv4Type.Echo_Reply, 264 | ICMPv4Type.Echo_Request}: 265 | raise AttributeError("Only Echo Requests/Replies have seqno") 266 | 267 | return struct.unpack("!HH", self.Payload)[1] 268 | 269 | -------------------------------------------------------------------------------- /connvitals/ping.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Comcast Cable Communications Management, LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | This module defines a class and utilities to manage icmp/icmpv6 echo requests 17 | and replies to remote hosts. 18 | """ 19 | 20 | import socket 21 | import struct 22 | import time 23 | import sys 24 | import math 25 | from . import utils 26 | from . import icmp 27 | 28 | def icmpParse(pkt: bytes, ipv6: bool) -> int: 29 | """ 30 | Parses an icmp packet, returning its sequence number. 31 | 32 | If the packet is found to be not an echo reply, this will 33 | immediately return -1, indicating that this packet 34 | should be disregarded. 35 | """ 36 | try: 37 | if ipv6: 38 | if pkt[0] == 129: 39 | return struct.unpack("!H", pkt[6:8])[0] 40 | return -1 41 | if pkt[20] == 0: 42 | return struct.unpack("!H", pkt[26:28])[0] 43 | return -1 44 | except (IndexError, struct.error): 45 | return -1 46 | 47 | class Pinger(): 48 | """ 49 | A data structure that handles icmp pings to a remote machine. 50 | """ 51 | def __init__(self, host: utils.Host, payload: bytes): 52 | """ 53 | Inializes a socket connection to the host on port 22, and returns a Pinger object 54 | referencing it. 55 | """ 56 | 57 | self.sock, self.icmpParse, self.mkPkt = None, None, None 58 | 59 | if host.family == socket.AF_INET6: 60 | self.sock = socket.socket(host.family, socket.SOCK_RAW, proto=socket.IPPROTO_ICMPV6) 61 | self.icmpParse = self._icmpv6Parse 62 | self.mkPkt = self._mkPkt6 63 | else: 64 | self.sock = socket.socket(host.family, socket.SOCK_RAW, proto=socket.IPPROTO_ICMP) 65 | self.icmpParse = self._icmpv4Parse 66 | self.mkPkt = self._mkPkt4 67 | 68 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 69 | 70 | self.sock.settimeout(1) 71 | self.payload = payload 72 | 73 | #Build a socket object 74 | self.host = host 75 | 76 | self.timestamps = {} 77 | 78 | def sendAll(self, num:int) -> utils.PingResult: 79 | """ 80 | Sends all pings sequentially in one thread 81 | """ 82 | for i in range(num): 83 | pkt = icmp.ICMPPkt(self.host, payload=struct.pack("!HH", 2, i)) 84 | self.timestamps[i] = time.time() 85 | 86 | try: 87 | self.sock.sendto(bytes(pkt), (self.host.addr, 0)) 88 | except Exception as e: 89 | utils.err("Network is unreachable... (%s)" % e) 90 | 91 | return self.recvAll(num) 92 | 93 | def recvAll(self, num:int) -> utils.PingResult: 94 | """ 95 | Recieves and parses all packets 96 | """ 97 | maxPacketLen, found = 100 + len(self.payload), 0 98 | pkts = [-1,]*num 99 | 100 | while found < num: 101 | 102 | try: 103 | pkt, addr = self.sock.recvfrom(maxPacketLen) 104 | except (socket.timeout, TimeoutError): 105 | # If we hit a socket timeout, we'll consider the packet dropped 106 | found += 1 107 | continue 108 | 109 | if addr[0] == self.host[0]: 110 | seqno = self.icmpParse(pkt) 111 | if seqno in range(len(pkts)): 112 | pkts[seqno] = time.time() - self.timestamps[seqno] 113 | found += 1 114 | 115 | # All packets collected; parse and return the results 116 | rtt, lost = [], 0 117 | for pkt in pkts: 118 | if pkt > 0: 119 | rtt.append(pkt*1000) 120 | else: 121 | lost += 1 122 | 123 | if lost == num: 124 | return utils.PingResult(-1, -1, -1, -1, 100.) 125 | 126 | try: 127 | avg = sum(rtt) / len(rtt) 128 | std = 0. 129 | for item in rtt: 130 | std += (avg - item) ** 2 131 | std /= len(rtt) - 1 132 | std = math.sqrt(std) 133 | except ZeroDivisionError: 134 | std = 0. 135 | 136 | return utils.PingResult(min(rtt), avg, max(rtt), std, lost/num * 100.0) 137 | 138 | def ping(self, seqno: int) -> float: 139 | """ 140 | Sends a single icmp packet to the remote host. 141 | Returns the round-trip time (in ms) between packet send and receipt 142 | or 0 if packet was not received. 143 | """ 144 | pkt = icmp.ICMPPkt(self.host, payload=struct.pack("!HH", 2, seqno)) 145 | 146 | # I set time here so that rtt includes the device latency 147 | self.timestamps[seqno] = time.time() 148 | 149 | try: 150 | # ICMP has no notion of port numbers 151 | self.sock.sendto(bytes(pkt), (self.host.addr, 0)) 152 | except Exception as e: 153 | #Sometimes, when the network is unreachable this will erroneously report that there's an 154 | #'invalid argument', which is impossible since the hostnames are coming straight from 155 | #`socket` itself 156 | raise Exception("Network is unreachable... (%s)" % e) 157 | return self.recv() 158 | 159 | @staticmethod 160 | def _icmpv4Parse(pkt: bytes) -> int: 161 | """ 162 | Attemtps to parse an icmpv4 packet, returning the sequence number if parsing succeds, 163 | or -1 otherwise. 164 | """ 165 | try: 166 | if pkt[20] == 0: 167 | return struct.unpack("!H", pkt[26:28])[0] 168 | except (IndexError, struct.error): 169 | pass 170 | return -1 171 | 172 | @staticmethod 173 | def _icmpv6Parse(pkt: bytes) -> int: 174 | """ 175 | Attemtps to parse an icmpv6 packet, returning the sequence number if parsing succeds, 176 | or -1 otherwise. 177 | """ 178 | try: 179 | if pkt[0] == 0x81: 180 | return struct.unpack("!H", pkt[6:8])[0] 181 | except (IndexError, struct.error): 182 | pass 183 | return -1 184 | 185 | def _mkPkt4(self, seqno: int) -> bytes: 186 | """ 187 | Contsructs and returns an ICMPv4 packet 188 | """ 189 | header = struct.pack("!BBHHH", 8, 0, 0, 2, seqno) 190 | checksum = self._checksum4(header + self.payload) 191 | return struct.pack("!BBHHH", 8, 0, checksum, 2, seqno) + self.payload 192 | 193 | def _mkPkt6(self, seqno: int) -> bytes: 194 | """ 195 | Contsructs and returns an ICMPv6 packet 196 | """ 197 | header = struct.pack("!BBHHH", 0x80, 0, 0, 2, seqno) 198 | checksum = self._checksum6(header) 199 | return struct.pack("!BBHHH", 0x80, 0, checksum, 2, seqno) + self.payload 200 | 201 | @staticmethod 202 | def _checksum4(pkt: bytes) -> int: 203 | """ 204 | calculates and returns the icmpv4 checksum of 'pkt' 205 | """ 206 | 207 | countTo = len(pkt) // 2 * 2 208 | total, count = 0, 0 209 | 210 | # Handle bytes in pairs (decoding as short ints) 211 | loByte, hiByte = 0, 0 212 | while count < countTo: 213 | if sys.byteorder == "little": 214 | loByte = pkt[count] 215 | hiByte = pkt[count + 1] 216 | else: 217 | loByte = pkt[count + 1] 218 | hiByte = pkt[count] 219 | total += hiByte * 256 + loByte 220 | count += 2 221 | 222 | # Handle last byte if applicable (odd-number of bytes) 223 | # Endianness should be irrelevant in this case 224 | if countTo < len(pkt): # Check for odd length 225 | total += pkt[len(pkt) - 1] 226 | 227 | total &= 0xffffffff # Truncate sum to 32 bits (a variance from ping.c, which 228 | # uses signed ints, but overflow is unlikely in ping) 229 | 230 | total = (total >> 16) + (total & 0xffff) # Add high 16 bits to low 16 bits 231 | total += (total >> 16) # Add carry from above (if any) 232 | 233 | return socket.htons((~total) & 0xffff) 234 | 235 | def _checksum6(self, pkt: bytes) -> int: 236 | """ 237 | calculates and returns the icmpv6 checksum of pkt 238 | """ 239 | laddr = socket.inet_pton(self.host[1], self.sock.getsockname()[0]) 240 | raddr = socket.inet_pton(*reversed(self.host)) 241 | # IPv6 Pseudo-Header used for checksum calculation as specified by 242 | # RFC 2460 (https://tools.ieft.org/html/rfc2460) 243 | psh = laddr + raddr + struct.pack("!I", len(pkt)) + b'\x00\x00\x00:' 244 | # This last bit is the 4-byte-packed icmp6 protocol number (58 or 0xa3) 245 | 246 | 247 | total, packet = 0, psh+pkt 248 | 249 | # Sum all 2-byte words 250 | num_words = len(packet) // 2 251 | for chunk in struct.unpack("!%sH" % num_words, packet[0:num_words*2]): 252 | total += chunk 253 | 254 | # Add any left-over byte (for odd-length packets) 255 | if len(packet) % 2: 256 | total += ord(packet[-1]) << 8 257 | 258 | # Fold 32-bits into 16-bits 259 | total = (total >> 16) + (total & 0xffff) 260 | total += total >> 16 261 | return ~total + 0x10000 & 0xffff 262 | 263 | def recv(self) -> float: 264 | """ 265 | Recieves each ping sent. 266 | """ 267 | # If a packet is not an echo reply, icmpParse will give its seqno as -1 268 | # This lets us disregard packets from traceroutes immediately 269 | maxlen = 100+len(self.payload) #this should be enough to cover headers and whatnot 270 | 271 | while True: 272 | 273 | try: 274 | pkt, addr = self.sock.recvfrom(maxlen) 275 | except (socket.timeout, TimeoutError): 276 | return -1 277 | 278 | # The packet must have actually come from the host we pinged 279 | if addr[0] == self.host[0]: 280 | seqno = self.icmpParse(pkt) 281 | if seqno >= 0: 282 | return time.time() - self.timestamps[seqno] 283 | 284 | 285 | def __enter__(self) -> "Pinger": 286 | """ 287 | Context-managed instantiation 288 | """ 289 | return self 290 | 291 | def __exit__(self, exc_type, exc_value, traceback): 292 | """ 293 | Context-managed cleanup 294 | """ 295 | self.sock.close() 296 | 297 | if exc_type and exc_value: 298 | utils.error(exc_type("Unknown error occurred while pinging")) 299 | utils.error(exc_type(exc_value)) 300 | if traceback: 301 | utils.warn(traceback) 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # connvitals 2 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 3 | 4 | Checks a machine's connection to a specific host or list of hosts in terms of packet loss, icmp latency, routing, and anything else that winds up getting added. 5 | 6 | *Note: Does not recognize duplicate hosts passed on `argv` and will test each as though unique.* 7 | 8 | *Note: Under normal execution conditions, requires super-user privileges to run.* 9 | 10 | ## Dependencies 11 | The utility runs on Python 3 (tested 3.6.3), but requires no non-standard external modules. 12 | 13 | However, in most cases you will need `setuptools` after installation, and if you are using an older version of Python (< 3.5) then you will need to install the backport of `typing`. These should be handled for you if you are using an `.rpm` file or `pip` to install `connvitals`. 14 | 15 | ## Installation 16 | ### Binary packages 17 | Binary packages are offered in `.rpm` format for Fedora/CentOS/RHEL and `.whl` format for all other operating systems under '[Releases](https://github.com/Comcast/connvitals/releases)'. 18 | 19 | ### Via `pip` (standard) 20 | By far the simplest way to install this package is to simply use `pip` like so: 21 | ``` 22 | pip install connvitals 23 | ``` 24 | Note that it's likely you'll need to either run this command as an administrator (Windows), with `sudo` (Everything Else), or with the `--user` option (Everything Including Windows). 25 | 26 | ### Via `pip` (From This Repository) 27 | If for some reason the standard python package index is unavailable to you, you can install directly from this repository without needing to manually download it by running 28 | ```bash 29 | user@hostname ~ $ pip install git+https://github.com/Comcast/connvitals.git#egg=connvitals 30 | ``` 31 | Note that you may need to run this command as root/with `sudo` or with `--user`, depending on your `pip` installation. Also ensure that `pip` is installing packages for Python 3.x. Typically, if both Python2 and Python3 exist on a system with `pip` installed for both, the `pip` to use for Python3 packages is accessible as `pip3`. 32 | 33 | ### Manually 34 | To install manually, first download or clone this repository. Then, in the directory you downloaded/cloned it into, run the command 35 | ```bash 36 | user@hostname ~/connvitals $ python setup.py install 37 | ``` 38 | Note that it's highly likely that you will need to run this command as root/with `sudo`. Also ensure that the `python` command points to a valid Python3 interpreter (you can check with `python --version`). On many systems, it is common for `python` to point to a Python2 interpreter. If you have both Python3 and Python2 installed, it's common that they be accessible as `python3` and `python2`, respectively. 39 | Finally, if you are choosing this option because you do not have a Python3 `pip` installation, you may not have `setuptools` installed. On most 'nix distros, this can be installed without installing `pip` by running `sudo apt-get install python3-setuptools` (Debian/Ubuntu), `sudo pacman -S python3-setuptools` (Arch), `sudo yum install python3-setuptools` (RedHat/Fedora/CentOS), or `brew install python3-setuptools` (macOS with `brew` installed). 40 | 41 | ## Usage 42 | ```bash 43 | connvitals [ -h --help ] [ -V --version ] [ -H --hops HOPS ] [ -p --pings PINGS ] [ -P --no-ping ] [ -t --trace ] [ --payload-size PAYLOAD ] [ -s --port-scan ] host [ hosts... ] 44 | ``` 45 | 46 | * `hosts` - The host or hosts to check connection to. May be ipv4 addresses, ipv6 addresses, fqdn's, or any combination thereof. 47 | * `-h` or `--help` - Prints help text, then exits successfully. 48 | * `-V` or `--version` - Prints the program's version information, then exits successfully. 49 | * `-H` or `--hops` - Sets max hops for route tracing (default 30). 50 | * `-p` or `--pings` - Sets the number of pings to use for aggregate statistics (default 4). 51 | * `-P` or `--no-ping` - Don't run ping tests. 52 | * `-t` or `--trace` - Run route tracing. 53 | * `-j` or `--json` - Prints output as one line of JSON-formatted text. 54 | * `-s` or `--port-scan` - Perform a limited scan on each hosts' ports. 55 | * `--payload-size` - Sets the size (in B) of ping packet payloads (default 41). 56 | 57 | ### Output Format 58 | 59 | #### Normal Output 60 | For each host tested, results are printed in the newline-separated order "host->Ping Results->Route Trace Results->Port Scan Results" where "host" is the name of the host, as passed on argv. If the name passed for a host on `argv` is not what ends up being used to test connection vitals (e.g. the program may translate `google.com` into `216.58.218.206`), then the "host" line will contain `host-as-typed (host IP used)`. 61 | 62 | Ping tests output their results as a tab-separated list containing - in this order - minimum round-trip time in milliseconds (rtt), mean rtt, maximum rtt, rtt standard deviation, and packet loss in percent. If all packets are lost, the min/mean/max/std are all reported as -1. 63 | 64 | Route traces output their results as a list of network hops, separated from each other by newlines. Each network hop is itself a tab-separated list of data containing - in this order - a network address for the machine this hop ended at, and the rtt of a packet traversing this route. If the packet was lost, a star (`*`) is shown instead of an address and rtt. 65 | 66 | Port scans check for http(s) servers on ports 80 and 443, and MySQL servers running on port 3306. It outputs its results as a tab-separated list containing - in this order - port 80 results, port 443 results, port 3306 results. Results for ports 80 and 443 consist of sending a `HEAD / HTTP/1.1` request and recording "rtt (in milliseconds), response code, server" from the server's response. "server" will be the contents of the "Server" header if found within the first kilobyte of the response, but if it is not found will simply be "Unknown". Port 3306 results report the version of the MySQL server listening on that port if one is found (Note that this version number may be mangled if the server allows unauthenticated connection or supports some other automatic authentication mechanism for the machine running connvitals). If a server is not found on a port, its results are reported as "None", indicating no listening server. If a server on port 80 expects encryption or a server on port 443 does not expect encryption, they will be "erroneously" reported as not existing. 67 | 68 | Example Output (with localhost running mysql server): 69 | 70 | ```bash 71 | root@hostname / # connvitals -stp 100 google.com 2607:f8b0:400f:807::200e localhost 72 | google.com (172.217.3.14) 73 | 3.543 4.955 11.368 1.442 0.000 74 | 10.169.240.1 3.108 75 | 10.168.253.8 2.373 76 | 10.168.254.252 3.659 77 | 10.168.255.226 2.399 78 | 198.178.8.94 3.059 79 | 69.241.22.33 51.104 80 | 68.86.103.13 16.470 81 | 68.86.92.121 5.488 82 | 68.86.86.77 4.257 83 | 68.86.83.6 3.946 84 | 173.167.58.142 5.290 85 | * 86 | 216.239.49.247 4.491 87 | 172.217.3.14 3.927 88 | 56.446, 200, gws 75.599, 200, gws None 89 | 2607:f8b0:400f:807::200e 90 | 3.446 4.440 12.422 1.526 0.000 91 | 2001:558:1418:49::1 8.846 92 | 2001:558:3da:74::1 1.453 93 | 2001:558:3da:6f::1 2.955 94 | 2001:558:3da:1::2 2.416 95 | 2001:558:3c2:15::1 2.605 96 | 2001:558:fe1c:6::1 47.516 97 | 2001:558:1c0:65::1 45.442 98 | 2001:558:0:f71e::1 9.165 99 | * 100 | * 101 | 2001:559:0:9::6 3.984 102 | * 103 | 2001:4860:0:1::10ad 3.970 104 | 2607:f8b0:400f:807::200e 3.891 105 | 57.706, 200, gws 77.736, 200, gws None 106 | localhost (127.0.0.1) 107 | 0.045 0.221 0.665 0.112 1.000 108 | 127.0.0.1 0.351 109 | None None 0.165, 5.7.2 110 | ``` 111 | 112 | #### JSON Output Format 113 | The JSON output format option (`-j` or `--json`) will render the output on one line. Each host is represented as an object, indexed by its **address**. This is not necessarily the same as the host as given on the command line, which may be found as an attribute of the host, named `'name'`. 114 | Results for ping tests are a dictionary attribute named `'ping'`, with floating point values labeled as `'min'`, `'avg'`, `'max'`, `'std'` and `'loss'`. As with all floating point numbers in json output, these values are **not rounded or truncated** and are printed exactly as calculated, to the greatest degree of precision afforded by the system. 115 | Route traces are output as a list attribute, labeled `'trace'`, where each each step in the route is itself a list. The first element in each list is either the address of the discovered host at that point in the route, or the special string `'*'` which indicates the packet was lost and no host was discovered at this point. The second element, if it exists, is a floating point number giving the round-trip-time of the packet sent at this step, in milliseconds. Once again, unlike normal output format, these floating point numbers **are not rounded or truncated** and are printed exactly as calculated, to the greatest degree of precision afforded by the system. 116 | Port scans are represented as a dictionary attribute named `'scan'`. The label of each element of `'scan'` is the name of the server checked for. `'http'` and `'https'` results will report a dictionary of values containing: 117 | * `'rtt'` - the time taken for the server to respond 118 | * `'response code'` - The decimal representation of the server's response code to a `HEAD / HTML/1.1` request. 119 | * `'server'` - the name of the server, if found within the first kilobyte of the server's response, otherwise "Unknown". 120 | `'mysql'` fields will also contain a dictionary of values, and that dictionary should also contain the `'rtt'` field with the same meaning as for `'http'` and `'https'`, but will replace the other two fields used by those protocols with `'version'`, which will give the version number of the MySQL server. 121 | If any of these three server types is not detected, the value of its label will be the string 'None', rather than a dictionary of values. 122 | 123 | Example JSON Output (with localhost running mysql server): 124 | ```bash 125 | root@hostname / # sudo connvitals -j --port-scan -tp 100 google.com 2607:f8b0:400f:807::200e localhost 126 | ``` 127 | ```json 128 | {"addr":"172.217.3.14","name":"google.com","ping":{"min": 3.525257110595703, "avg": 4.422152042388916, "max": 5.756855010986328, "std": 0.47761748430602524, "loss": 0.0},"trace":[["*"], ["10.168.253.8", 2.187013626098633], ["10.168.254.252", 4.266977310180664], ["10.168.255.226", 3.283977508544922], ["198.178.8.94", 2.7751922607421875], ["69.241.22.33", 3.7970542907714844], ["68.86.103.13", 3.8001537322998047], ["68.86.92.121", 7.291316986083984], ["68.86.86.77", 5.874156951904297], ["68.86.83.6", 4.465818405151367], ["173.167.58.142", 4.443883895874023], ["*"], ["216.239.49.231", 4.090785980224609], ["172.217.3.14", 4.895925521850586]],"scan":{"http": {"rtt": 59.095, "response code": "200", "server": "gws"}, "https": {"rtt": 98.238, "response code": "200", "server": "gws"}, "mysql": "None"}}} 129 | {"addr":"2607:f8b0:400f:807::200e","name":"2607:f8b0:400f:807::200e","ping":{"min": 3.62396240234375, "avg": 6.465864181518555, "max": 24.2769718170166, "std": 5.133322111766303, "loss": 0.0},"trace":[["*"], ["2001:558:3da:74::1", 1.9710063934326172], ["2001:558:3da:6f::1", 2.904176712036133], ["2001:558:3da:1::2", 2.5751590728759766], ["2001:558:3c2:15::1", 2.7141571044921875], ["2001:558:fe1c:6::1", 4.7512054443359375], ["2001:558:1c0:65::1", 3.927946090698242], ["*"], ["*"], ["2001:558:0:f8c1::2", 3.635406494140625], ["2001:559:0:18::2", 3.8270950317382812], ["*"], ["2001:4860:0:1::10ad", 4.517078399658203], ["2607:f8b0:400f:807::200e", 3.91387939453125]],"scan":{"http": {"rtt": 51.335, "response code": "200", "server": "gws"}, "https": {"rtt": 70.521, "response code": "200", "server": "gws"}, "mysql": "None"}}} 130 | {"addr":"127.0.0.1","name":"localhost","ping":{"min": 0.04792213439941406, "avg": 0.29621124267578125, "max": 0.5612373352050781, "std": 0.0995351687014057, "loss": 0.0},"trace":[["127.0.0.1", 1.9199848175048828]],"scan":{"http": "None", "https": "None", "mysql": {"rtt": 0.148, "version": "5.7.2"}}}} 131 | 132 | ``` 133 | 134 | #### Error Output Format 135 | When an error occurs, it is printed to `stderr` in the following format: 136 | ``` 137 | EE: : : - 138 | ``` 139 | `EE: ` is prepended for ease of readability in the common case that stdout and stderr are being read/parsed from the same place. `` is commonly just `str` or `Exception`, but can in some cases represent more specific error types. `` holds extra information describing why the error occurred. Note that stack traces are not commonly logged, and only occur when the program crashes for unforseen reasons. `` is the time at which the error occurred, given in the system's `ctime` format, which will usually look like `Mon Jan 1 12:59:59 2018`. 140 | 141 | Some errors do not affect execution in a large scope, and are printed largely for debugging purposes. These are printed as warnings to `stderr` in the following format: 142 | ``` 143 | WW: - 144 | ``` 145 | Where `WW: ` is prepended both for ease of readability and to differentiate it from an error, `` is the warning message, and `` is the time at which the warning was issued, given in the system's `ctime` format. 146 | 147 | In the case that `stderr` is a tty, `connvitals` will attempt to print errors in red and warnings in yellow, using ANSI control sequences (supports all VT/100-compatible terminal emulators). 148 | -------------------------------------------------------------------------------- /connvitals/ports.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Comcast Cable Communications Management, LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | This module contains functions for scanning some specific hosts for specific 17 | information. Currently has functionality for http(s) servers on ports 80/443 18 | and MySQL servers on port 3306. 19 | """ 20 | 21 | import socket 22 | import time 23 | import multiprocessing.pool 24 | import typing 25 | import ssl 26 | from . import utils 27 | 28 | class Scanner(): 29 | """ 30 | Holds persistent information that can be used to repeatedly 31 | do port scans. 32 | """ 33 | 34 | # Message constant - so it doesn't need to be re-allocated at runtime. 35 | HTTP_MSG = b'HEAD / HTTP/1.1\r\nConnection: Keep-Alive\r\nHost: ' 36 | 37 | 38 | def __init__(self, host:utils.Host): 39 | """ 40 | Sets up this scanner, including opening three sockets for 41 | scanning. 42 | """ 43 | 44 | self.host = host 45 | self.HTTP_MSG += self.host.addr.encode() + b'\r\n\r\n' 46 | 47 | # Sets up two TCP sockets, each with a dedicated message 48 | # buffer - 1 to check the http port, and 1 to check the 49 | # https port. 50 | self.buffers = [bytearray(1024), bytearray(1024)] 51 | self.socks = [ 52 | socket.socket(family=host.family), 53 | ssl.wrap_socket(socket.socket(family=host.family), ssl_version=3), 54 | ] 55 | 56 | for sock in self.socks: 57 | sock.settimeout(0.08) 58 | 59 | 60 | # When HTTP(S) connections fail, close them immediately. The individual 61 | # scanners will re-attempt the connection (mysql server connections) 62 | # shouldn't persist, because there's no mysql 'NOOP' to my knowledge). 63 | try: 64 | self.socks[0].connect((self.host.addr, 80)) 65 | except (ConnectionRefusedError, socket.timeout, socket.gaierror): 66 | utils.warn("Connection Refused by %s on port 80" % self.host.addr) 67 | self.socks[0].close() 68 | 69 | try: 70 | self.socks[1].connect((self.host.addr, 443)) 71 | except ssl.SSLError as e: 72 | utils.warn("SSL handshake with %s failed: %s" % (url[0], e)) 73 | self.socks[1].close() 74 | except (ConnectionRefusedError, socket.timeout, socket.gaierror): 75 | utils.warn("Connection Refused by %s on port 443" % self.host.addr) 76 | self.socks[1].close() 77 | 78 | 79 | def __enter__(self) -> 'Scanner': 80 | """ 81 | Context-managed `Scanner` object. 82 | """ 83 | 84 | return self 85 | 86 | def __exit__(self, exc_type, exc_value, traceback): 87 | """ 88 | Context-cleanup for `Scanner` object. 89 | """ 90 | for sock in self.socks: 91 | sock.shutdown(socket.SHUT_RDWR) 92 | sock.close() 93 | 94 | if exc_type and exc_value: 95 | utils.error("Unknown error occurred (Traceback: %s)" % traceback) 96 | utils.error(exc_type(exc_value), True) 97 | 98 | def __del__(self): 99 | """ 100 | Non-context-managed cleanup. 101 | """ 102 | try: 103 | for sock in self.socks: 104 | sock.close() 105 | except AttributeError: 106 | # Context-management handled the sockets already 107 | pass 108 | 109 | 110 | def scan(self, pool:typing.Union[multiprocessing.pool.Pool, bool] = None) -> utils.ScanResult: 111 | """ 112 | Performs a full portscan of the host, and returns a format-able result. 113 | 114 | If the `pool` argument is given, it should be either a usable 115 | `multiprocessing.pool.Pool` ancestor (i.e. ThreadPool or Pool), or a 116 | boolean. If `pool` is literally `True`, a new ThreadPool will be 117 | created to perform the scan. 118 | 119 | If `pool` is `None`, each scan is done sequentially. 120 | """ 121 | if pool: 122 | if pool is True: 123 | with multiprocessing.pool.ThreadPool(3) as p: 124 | httpresult = p.apply_async(self.http, ()) 125 | httpsresult = p.apply_async(self.https, ()) 126 | mysqlresult = p.apply_async(self.mysql, ()) 127 | 128 | return utils.ScanResult(httpresult.get(), httpsresult.get(), mysqlresult.get()) 129 | 130 | httpresult = pool.apply_async(self.http, ()) 131 | httpsresult = pool.apply_async(self.https, ()) 132 | mysqlresult = pool.apply_async(self.mysql, ()) 133 | 134 | return utils.ScanResult(httpresult.get(), httpsresult.get(), mysqlresult.get()) 135 | 136 | return utils.ScanResult(self.http(), self.https(), self.mysql()) 137 | 138 | def http(self) -> typing.Optional[typing.Tuple[float, str, str]]: 139 | """ 140 | Checks for http content on port 80. 141 | 142 | This uses a HEAD / HTTP/1.1 request, and returns a tuple containing 143 | the total latency, the server's status code and reason, and the server's 144 | name/version (if found in the first kilobyte of data). 145 | """ 146 | 147 | s = self.socks[0] 148 | 149 | try: 150 | rtt = time.time() 151 | s.send(self.HTTP_MSG) 152 | _ = s.recv_into(self.buffers[0]) 153 | rtt = time.time() - rtt 154 | 155 | except OSError: 156 | # Possibly the connection was closed; try to re-open. 157 | try: 158 | self.socks[0].close() 159 | self.socks[0] = socket.socket(family=self.host.family) 160 | self.socks[0].settimeout(0.08) 161 | self.socks[0].connect((self.host.addr, 80)) 162 | rtt = time.time() 163 | self.socks[0].send(self.HTTP_MSG) 164 | _ = self.socks[0].recv_into(self.buffers[0]) 165 | rtt = time.time() - rtt 166 | 167 | except (OSError, socket.gaierror, socket.timeout) as e: 168 | # If this happens, the server likely went down 169 | utils.warn("Could not connect to %s:80 - %s" % (self.host.addr, e)) 170 | return None 171 | 172 | except (socket.gaierror, socket.timeout) as e: 173 | utils.warn("Could not connect to %s:80 - %s" % (self.host.addr, e)) 174 | return None 175 | 176 | if not self.buffers[0]: 177 | return None 178 | 179 | status = self.buffers[0][9:12].decode() 180 | 181 | try: 182 | srv = self.buffers[0].index(b'Server: ') 183 | srv = self.buffers[0][srv+8:self.buffers[0].index(b'\r', srv)].decode() 184 | except ValueError: 185 | # Server header not found 186 | return rtt*1000, status, "Unkown" 187 | else: 188 | return rtt*1000, status, srv 189 | finally: 190 | # Creating a new buffer is faster than clearing the old one 191 | self.buffers[0] = bytearray(1024) 192 | 193 | def https(self) -> typing.Optional[typing.Tuple[float, str, str]]: 194 | """ 195 | Checks for http content on port 433. 196 | 197 | This uses a HEAD / HTTP/1.1 request, and returns a tuple containing 198 | the total latency, the server's status code and reason, and the server's 199 | name/version (if found in the first kilobyte of data). 200 | 201 | Note that this is principally the same as `self.http`, but in the interest 202 | of favoring time optimization (no conditional branching, fewer dereferences) 203 | over space optimization the process is repeated nearly verbatim. 204 | """ 205 | 206 | s = self.socks[1] 207 | try: 208 | rtt = time.time() 209 | s.send(self.HTTP_MSG) 210 | _ = s.recv_into(self.buffers[1]) 211 | rtt = time.time() - rtt 212 | except ssl.SSLError as e: 213 | utils.warn("SSL handshake with %s failed: %s" % (url[0], e)) 214 | return None 215 | 216 | except OSError: 217 | # Possibly the connection was closed; try to re-open. 218 | try: 219 | self.socks[1].close() 220 | self.socks[1] = ssl.wrap_socket(socket.socket(family=self.host.family), ssl_version=3) 221 | self.socks[1].settimeout(0.08) 222 | self.socks[1].connect((self.host.addr, 443)) 223 | rtt = time.time() 224 | self.socks[1].send(self.HTTP_MSG) 225 | _ = self.socks[1].recv_into(self.buffers[1]) 226 | rtt = time.time() - rtt 227 | except ssl.SSLError as e: 228 | utils.warn("SSL handshake with %s failed: %s" % (url[0], e)) 229 | return None 230 | 231 | except (OSError, socket.gaierror, socket.timeout) as e: 232 | # If this happens, the server likely went down 233 | utils.warn("Could not connect to %s:443 - %s" % (self.host.addr, e)) 234 | return None 235 | 236 | except (socket.gaierror, socket.timeout) as e: 237 | utils.warn("Could not connect to %s:443 - %s" % (self.host.addr, e)) 238 | return None 239 | 240 | if not self.buffers[1]: 241 | return None 242 | 243 | status = self.buffers[1][9:12].decode() 244 | 245 | try: 246 | srv = self.buffers[1].index(b'Server: ') 247 | srv = self.buffers[1][srv+8:self.buffers[1].index(b'\r', srv)].decode() 248 | except ValueError: 249 | # Server header not found 250 | return rtt*1000, status, "Unkown" 251 | else: 252 | return rtt*1000, status, srv 253 | finally: 254 | # Creating a new buffer is faster than clearing the old one 255 | self.buffers[1] = bytearray(1024) 256 | 257 | def mysql(self) -> typing.Optional[typing.Tuple[float, str]]: 258 | """ 259 | Checks for a MySQL server running on port 3306. 260 | 261 | Returns a tuple containing the total latency and the server version if one is found. 262 | """ 263 | 264 | with socket.socket(family=self.host.family) as s: 265 | 266 | try: 267 | rtt = time.time() 268 | s.connect((self.host.addr, 3306)) 269 | _ = s.recv_into(self.buffers[2]) 270 | ret = sock.recv(1024) 271 | 272 | except (OSError, socket.gaierror, socket.timeout) as e: 273 | utils.warn("Could not connect to %s:3306 - %s" % (self.host.addr, e)) 274 | return None 275 | 276 | rtt = (time.time() - rtt)*1000 277 | try: 278 | return rtt, ret[5:10].decode() 279 | except UnicodeError: 280 | utils.warn("Server at %s:3306 doesn't appear to be mysql." % self.host.addr) 281 | return rtt, "Unknown" 282 | 283 | 284 | # Functional implementation provided for convenience/legacy support 285 | 286 | 287 | def http(url: utils.Host, port: int=80) -> typing.Optional[typing.Tuple[float, str, str]]: 288 | """ 289 | Checks for http content being served by url on a port passed in ssl. 290 | (If ssl is 443, wraps the socket with ssl to communicate HTTPS) 291 | Returns a HEAD request's status code if a server is found, else None 292 | """ 293 | 294 | # Create socket (wrap for ssl as needed) 295 | sock = socket.socket(family=url[1]) 296 | if port == 443: 297 | sock = ssl.wrap_socket(sock, ssl_version=3) 298 | sock.settimeout(0.08) 299 | 300 | # Send request, and return "None" if anything goes wrong 301 | try: 302 | rtt = time.time() 303 | sock.connect((url[0], port)) 304 | sock.send(b"HEAD / HTTP/1.1\r\n\r\n") 305 | ret = sock.recv(1000) 306 | rtt = time.time() - rtt 307 | except (OSError, ConnectionRefusedError, socket.gaierror, socket.timeout) as e: 308 | utils.error(Exception("Could not connect to %s: %s" % (url[0], e))) 309 | return None 310 | except ssl.SSLError as e: 311 | utils.warn("SSL handshake with %s failed: %s" % (url[0], e)) 312 | return None 313 | finally: 314 | sock.close() 315 | 316 | # Servers that enforce ssl encryption when our socket isn't wrapped - or don't 317 | # recognize encrypted requests when it is - will sometimes send empty responses 318 | if not ret: 319 | return None 320 | 321 | # Check for "Server" header if available. 322 | # Note - this assumes that both the contents of the "Server" header and the response code are 323 | # utf8-decodable, which may need to be patched in the future 324 | try: 325 | srv = ret.index(b'Server: ') 326 | except ValueError: 327 | return rtt*1000, ret[9:12].decode(), "Unkown" 328 | return rtt*1000, ret[9:12].decode(), ret[srv+8:ret.index(b'\r', srv)].decode() 329 | 330 | 331 | def mysql(url: utils.Host) -> typing.Optional[typing.Tuple[float, str]]: 332 | """ 333 | Checks for a MySQL server running on the host specified by url. 334 | Returns the server version if one is found, else None. 335 | """ 336 | 337 | sock = socket.socket(family=url[1]) 338 | sock.settimeout(0.08) 339 | try: 340 | rtt = time.time() 341 | sock.connect((url[0], 3306)) 342 | return (time.time() - rtt)* 1000, sock.recv(1000)[5:10].decode() 343 | except (UnicodeError, OSError, ConnectionRefusedError, socket.gaierror, socket.timeout) as e: 344 | utils.error(Exception("Could not connect to %s: %s" % (url[0], e))) 345 | return None 346 | finally: 347 | sock.close() 348 | 349 | def portScan(host:utils.Host, pool:multiprocessing.pool.Pool)-> typing.Tuple[str, utils.ScanResult]: 350 | """ 351 | Scans a host using a multiprocessing worker pool to see if a specific set of ports are open, 352 | possibly returning extra information in the case that they are. 353 | 354 | Returns a tuple of (host, information) where host is the ip of the host scanned and information 355 | is any and all information gathered from each port as a tuple in the order (80, 443). 356 | If the specified port is not open, its spot in the tuple will contain `None`, but will otherwise 357 | contain some information related to the port. 358 | """ 359 | 360 | # Dispatch the workers 361 | hypertext = pool.apply_async(http, (host,)) 362 | https = pool.apply_async(http, (host, 443)) 363 | mysqlserver = pool.apply_async(mysql, (host,)) 364 | 365 | # Collect and return 366 | return utils.ScanResult(hypertext.get(), https.get(), mysqlserver.get()) 367 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | connvitals 2 | ========== 3 | 4 | |License| 5 | 6 | Checks a machine's connection to a specific host or list of hosts in 7 | terms of packet loss, icmp latency, routing, and anything else that 8 | winds up getting added. 9 | 10 | *Note: Does not recognize duplicate hosts passed on ``argv`` and will 11 | test each as though unique.* 12 | 13 | *Note: Under normal execution conditions, requires super-user privileges 14 | to run.* 15 | 16 | Dependencies 17 | ------------ 18 | 19 | The utility runs on Python 3 (tested 3.6.3), but requires no 20 | non-standard external modules. 21 | 22 | However, in most cases you will need ``setuptools`` after installation, 23 | and if you are using an older version of Python (< 3.5) then you will 24 | need to install the backport of ``typing``. These should be handled for 25 | you if you are using an ``.rpm`` file or ``pip`` to install 26 | ``connvitals``. 27 | 28 | Installation 29 | ------------ 30 | 31 | Binary packages 32 | ~~~~~~~~~~~~~~~ 33 | 34 | Binary packages are offered in ``.rpm`` format for Fedora/CentOS/RHEL 35 | and ``.whl`` format for all other operating systems under 36 | '`Releases `__'. 37 | 38 | Via ``pip`` (standard) 39 | ~~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | By far the simplest way to install this package is to simply use ``pip`` 42 | like so: 43 | 44 | :: 45 | 46 | pip install connvitals 47 | 48 | Note that it's likely you'll need to either run this command as an 49 | administrator (Windows), with ``sudo`` (Everything Else), or with the 50 | ``--user`` option (Everything Including Windows). 51 | 52 | Via ``pip`` (From This Repository) 53 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 54 | 55 | If for some reason the standard python package index is unavailable to 56 | you, you can install directly from this repository without needing to 57 | manually download it by running 58 | 59 | .. code:: bash 60 | 61 | user@hostname ~ $ pip install git+https://github.com/connvitals.git#egg=connvitals 62 | 63 | Note that you may need to run this command as root/with ``sudo`` or with 64 | ``--user``, depending on your ``pip`` installation. Also ensure that 65 | ``pip`` is installing packages for Python 3.x. Typically, if both 66 | Python2 and Python3 exist on a system with ``pip`` installed for both, 67 | the ``pip`` to use for Python3 packages is accessible as ``pip3``. 68 | 69 | Manually 70 | ~~~~~~~~ 71 | 72 | To install manually, first download or clone this repository. Then, in 73 | the directory you downloaded/cloned it into, run the command 74 | 75 | .. code:: bash 76 | 77 | user@hostname ~/connvitals $ python setup.py install 78 | 79 | | Note that it's highly likely that you will need to run this command as 80 | root/with ``sudo``. Also ensure that the ``python`` command points to 81 | a valid Python3 interpreter (you can check with ``python --version``). 82 | On many systems, it is common for ``python`` to point to a Python2 83 | interpreter. If you have both Python3 and Python2 installed, it's 84 | common that they be accessible as ``python3`` and ``python2``, 85 | respectively. 86 | | Finally, if you are choosing this option because you do not have a 87 | Python3 ``pip`` installation, you may not have ``setuptools`` 88 | installed. On most 'nix distros, this can be installed without 89 | installing ``pip`` by running 90 | ``sudo apt-get install python3-setuptools`` (Debian/Ubuntu), 91 | ``sudo pacman -S python3-setuptools`` (Arch), 92 | ``sudo yum install python3-setuptools`` (RedHat/Fedora/CentOS), or 93 | ``brew install python3-setuptools`` (macOS with ``brew`` installed). 94 | 95 | Usage 96 | ----- 97 | 98 | .. code:: bash 99 | 100 | connvitals [ -h --help ] [ -V --version ] [ -H --hops HOPS ] [ -p --pings PINGS ] [ -P --no-ping ] [ -t --trace ] [ --payload-size PAYLOAD ] [ -s --port-scan ] host [ hosts... ] 101 | 102 | - ``hosts`` - The host or hosts to check connection to. May be ipv4 103 | addresses, ipv6 addresses, fqdn's, or any combination thereof. 104 | - ``-h`` or ``--help`` - Prints help text, then exits successfully. 105 | - ``-V`` or ``--version`` - Prints the program's version information, 106 | then exits successfully. 107 | - ``-H`` or ``--hops`` - Sets max hops for route tracing (default 30). 108 | - ``-p`` or ``--pings`` - Sets the number of pings to use for aggregate 109 | statistics (default 4). 110 | - ``-P`` or ``--no-ping`` - Don't run ping tests. 111 | - ``-t`` or ``--trace`` - Run route tracing. 112 | - ``-j`` or ``--json`` - Prints output as one line of JSON-formatted 113 | text. 114 | - ``-s`` or ``--port-scan`` - Perform a limited scan on each hosts' 115 | ports. 116 | - ``--payload-size`` - Sets the size (in B) of ping packet payloads 117 | (default 41). 118 | 119 | Output Format 120 | ~~~~~~~~~~~~~ 121 | 122 | Normal Output 123 | ^^^^^^^^^^^^^ 124 | 125 | For each host tested, results are printed in the newline-separated order 126 | "host->Ping Results->Route Trace Results->Port Scan Results" where 127 | "host" is the name of the host, as passed on argv. If the name passed 128 | for a host on ``argv`` is not what ends up being used to test connection 129 | vitals (e.g. the program may translate ``google.com`` into 130 | ``216.58.218.206``), then the "host" line will contain 131 | ``host-as-typed (host IP used)``. 132 | 133 | Ping tests output their results as a tab-separated list containing - in 134 | this order - minimum round-trip time in milliseconds (rtt), mean rtt, 135 | maximum rtt, rtt standard deviation, and packet loss in percent. If all 136 | packets are lost, the min/mean/max/std are all reported as -1. 137 | 138 | Route traces output their results as a list of network hops, separated 139 | from each other by newlines. Each network hop is itself a tab-separated 140 | list of data containing - in this order - a network address for the 141 | machine this hop ended at, and the rtt of a packet traversing this 142 | route. If the packet was lost, a star (``*``) is shown instead of an 143 | address and rtt. 144 | 145 | Port scans check for http(s) servers on ports 80 and 443, and MySQL 146 | servers running on port 3306. It outputs its results as a tab-separated 147 | list containing - in this order - port 80 results, port 443 results, 148 | port 3306 results. Results for ports 80 and 443 consist of sending a 149 | ``HEAD / HTTP/1.1`` request and recording "rtt (in milliseconds), 150 | response code, server" from the server's response. "server" will be the 151 | contents of the "Server" header if found within the first kilobyte of 152 | the response, but if it is not found will simply be "Unknown". Port 3306 153 | results report the version of the MySQL server listening on that port if 154 | one is found (Note that this version number may be mangled if the server 155 | allows unauthenticated connection or supports some other automatic 156 | authentication mechanism for the machine running connvitals). If a 157 | server is not found on a port, its results are reported as "None", 158 | indicating no listening server. If a server on port 80 expects 159 | encryption or a server on port 443 does not expect encryption, they will 160 | be "erroneously" reported as not existing. 161 | 162 | Example Output (with localhost running mysql server): 163 | 164 | .. code:: bash 165 | 166 | root@hostname / # connvitals -stp 100 google.com 2607:f8b0:400f:807::200e localhost 167 | google.com (172.217.3.14) 168 | 3.543 4.955 11.368 1.442 0.000 169 | 10.169.240.1 3.108 170 | 10.168.253.8 2.373 171 | 10.168.254.252 3.659 172 | 10.168.255.226 2.399 173 | 198.178.8.94 3.059 174 | 69.241.22.33 51.104 175 | 68.86.103.13 16.470 176 | 68.86.92.121 5.488 177 | 68.86.86.77 4.257 178 | 68.86.83.6 3.946 179 | 173.167.58.142 5.290 180 | * 181 | 216.239.49.247 4.491 182 | 172.217.3.14 3.927 183 | 56.446, 200, gws 75.599, 200, gws None 184 | 2607:f8b0:400f:807::200e 185 | 3.446 4.440 12.422 1.526 0.000 186 | 2001:558:1418:49::1 8.846 187 | 2001:558:3da:74::1 1.453 188 | 2001:558:3da:6f::1 2.955 189 | 2001:558:3da:1::2 2.416 190 | 2001:558:3c2:15::1 2.605 191 | 2001:558:fe1c:6::1 47.516 192 | 2001:558:1c0:65::1 45.442 193 | 2001:558:0:f71e::1 9.165 194 | * 195 | * 196 | 2001:559:0:9::6 3.984 197 | * 198 | 2001:4860:0:1::10ad 3.970 199 | 2607:f8b0:400f:807::200e 3.891 200 | 57.706, 200, gws 77.736, 200, gws None 201 | localhost (127.0.0.1) 202 | 0.045 0.221 0.665 0.112 1.000 203 | 127.0.0.1 0.351 204 | None None 0.165, 5.7.2 205 | 206 | JSON Output Format 207 | ^^^^^^^^^^^^^^^^^^ 208 | 209 | | The JSON output format option (``-j`` or ``--json``) will render the 210 | output on one line. Each host is represented as an object, indexed by 211 | its **address**. This is not necessarily the same as the host as given 212 | on the command line, which may be found as an attribute of the host, 213 | named ``'name'``. 214 | | Results for ping tests are a dictionary attribute named ``'ping'``, 215 | with floating point values labeled as ``'min'``, ``'avg'``, ``'max'``, 216 | ``'std'`` and ``'loss'``. As with all floating point numbers in json 217 | output, these values are **not rounded or truncated** and are printed 218 | exactly as calculated, to the greatest degree of precision afforded by 219 | the system. 220 | | Route traces are output as a list attribute, labeled ``'trace'``, 221 | where each each step in the route is itself a list. The first element 222 | in each list is either the address of the discovered host at that 223 | point in the route, or the special string ``'*'`` which indicates the 224 | packet was lost and no host was discovered at this point. The second 225 | element, if it exists, is a floating point number giving the 226 | round-trip-time of the packet sent at this step, in milliseconds. Once 227 | again, unlike normal output format, these floating point numbers **are 228 | not rounded or truncated** and are printed exactly as calculated, to 229 | the greatest degree of precision afforded by the system. 230 | | Port scans are represented as a dictionary attribute named ``'scan'``. 231 | The label of each element of ``'scan'`` is the name of the server 232 | checked for. ``'http'`` and ``'https'`` results will report a 233 | dictionary of values containing: 234 | | \* ``'rtt'`` - the time taken for the server to respond 235 | | \* ``'response code'`` - The decimal representation of the server's 236 | response code to a ``HEAD / HTML/1.1`` request. 237 | | \* ``'server'`` - the name of the server, if found within the first 238 | kilobyte of the server's response, otherwise "Unknown". 239 | | ``'mysql'`` fields will also contain a dictionary of values, and that 240 | dictionary should also contain the ``'rtt'`` field with the same 241 | meaning as for ``'http'`` and ``'https'``, but will replace the other 242 | two fields used by those protocols with ``'version'``, which will give 243 | the version number of the MySQL server. 244 | | If any of these three server types is not detected, the value of its 245 | label will be the string 'None', rather than a dictionary of values. 246 | 247 | Example JSON Output (with localhost running mysql server): 248 | 249 | .. code:: bash 250 | 251 | root@hostname / # sudo connvitals -j --port-scan -tp 100 google.com 2607:f8b0:400f:807::200e localhost 252 | 253 | .. code:: json 254 | 255 | {"addr":"172.217.3.14","name":"google.com","ping":{"min": 3.525257110595703, "avg": 4.422152042388916, "max": 5.756855010986328, "std": 0.47761748430602524, "loss": 0.0},"trace":[["*"], ["10.168.253.8", 2.187013626098633], ["10.168.254.252", 4.266977310180664], ["10.168.255.226", 3.283977508544922], ["198.178.8.94", 2.7751922607421875], ["69.241.22.33", 3.7970542907714844], ["68.86.103.13", 3.8001537322998047], ["68.86.92.121", 7.291316986083984], ["68.86.86.77", 5.874156951904297], ["68.86.83.6", 4.465818405151367], ["173.167.58.142", 4.443883895874023], ["*"], ["216.239.49.231", 4.090785980224609], ["172.217.3.14", 4.895925521850586]],"scan":{"http": {"rtt": 59.095, "response code": "200", "server": "gws"}, "https": {"rtt": 98.238, "response code": "200", "server": "gws"}, "mysql": "None"}}} 256 | {"addr":"2607:f8b0:400f:807::200e","name":"2607:f8b0:400f:807::200e","ping":{"min": 3.62396240234375, "avg": 6.465864181518555, "max": 24.2769718170166, "std": 5.133322111766303, "loss": 0.0},"trace":[["*"], ["2001:558:3da:74::1", 1.9710063934326172], ["2001:558:3da:6f::1", 2.904176712036133], ["2001:558:3da:1::2", 2.5751590728759766], ["2001:558:3c2:15::1", 2.7141571044921875], ["2001:558:fe1c:6::1", 4.7512054443359375], ["2001:558:1c0:65::1", 3.927946090698242], ["*"], ["*"], ["2001:558:0:f8c1::2", 3.635406494140625], ["2001:559:0:18::2", 3.8270950317382812], ["*"], ["2001:4860:0:1::10ad", 4.517078399658203], ["2607:f8b0:400f:807::200e", 3.91387939453125]],"scan":{"http": {"rtt": 51.335, "response code": "200", "server": "gws"}, "https": {"rtt": 70.521, "response code": "200", "server": "gws"}, "mysql": "None"}}} 257 | {"addr":"127.0.0.1","name":"localhost","ping":{"min": 0.04792213439941406, "avg": 0.29621124267578125, "max": 0.5612373352050781, "std": 0.0995351687014057, "loss": 0.0},"trace":[["127.0.0.1", 1.9199848175048828]],"scan":{"http": "None", "https": "None", "mysql": {"rtt": 0.148, "version": "5.7.2"}}}} 258 | 259 | Error Output Format 260 | ^^^^^^^^^^^^^^^^^^^ 261 | 262 | When an error occurs, it is printed to ``stderr`` in the following 263 | format: 264 | 265 | :: 266 | 267 | EE: : : - 268 | 269 | ``EE:`` is prepended for ease of readability in the common case that 270 | stdout and stderr are being read/parsed from the same place. 271 | ```` is commonly just ``str`` or ``Exception``, but can in 272 | some cases represent more specific error types. ```` 273 | holds extra information describing why the error occurred. Note that 274 | stack traces are not commonly logged, and only occur when the program 275 | crashes for unforseen reasons. ```` is the time at which the 276 | error occurred, given in the system's ``ctime`` format, which will 277 | usually look like ``Mon Jan 1 12:59:59 2018``. 278 | 279 | Some errors do not affect execution in a large scope, and are printed 280 | largely for debugging purposes. These are printed as warnings to 281 | ``stderr`` in the following format: 282 | 283 | :: 284 | 285 | WW: - 286 | 287 | Where ``WW:`` is prepended both for ease of readability and to 288 | differentiate it from an error, ```` is the warning message, 289 | and ```` is the time at which the warning was issued, given 290 | in the system's ``ctime`` format. 291 | 292 | In the case that ``stderr`` is a tty, ``connvitals`` will attempt to 293 | print errors in red and warnings in yellow, using ANSI control sequences 294 | (supports all VT/100-compatible terminal emulators). 295 | 296 | .. |License| image:: https://img.shields.io/badge/License-Apache%202.0-blue.svg 297 | :target: https://opensource.org/licenses/Apache-2.0 298 | --------------------------------------------------------------------------------