From 0d8f2ca633f9431d65ec49f64077434995f318aa Mon Sep 17 00:00:00 2001 From: "Brennan W. Fieck" Date: Wed, 30 May 2018 09:21:44 -0600 Subject: [PATCH] Initial Commit. --- .gitignore | 16 +++ LICENSE | 202 +++++++++++++++++++++++++++ MANIFEST.in | 7 + NOTICE | 16 +++ README.md | 136 ++++++++++++++++++ README.rst | 275 ++++++++++++++++++++++++++++++++++++ connvitals/__init__.py | 75 ++++++++++ connvitals/collector.py | 158 +++++++++++++++++++++ connvitals/config.py | 117 ++++++++++++++++ connvitals/ping.py | 295 +++++++++++++++++++++++++++++++++++++++ connvitals/ports.py | 107 ++++++++++++++ connvitals/traceroute.py | 84 +++++++++++ connvitals/utils.py | 189 +++++++++++++++++++++++++ setup.py | 208 +++++++++++++++++++++++++++ 14 files changed, 1885 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 NOTICE create mode 100644 README.md create mode 100644 README.rst create mode 100644 connvitals/__init__.py create mode 100644 connvitals/collector.py create mode 100644 connvitals/config.py create mode 100644 connvitals/ping.py create mode 100644 connvitals/ports.py create mode 100644 connvitals/traceroute.py create mode 100644 connvitals/utils.py create mode 100755 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcf224c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Sublime project files +*.sublime-* + +# ViM swapfiles +*.swp +*.swp~ + +# Python Byte-Code caches +*.pyc +__pycache__ + +# Setuputils build directories +build +dist +*.egg-info + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7447693 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +# License information +include LICENSE +include NOTICE + +# README in reStructuredText format +README.rst + diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..948ad52 --- /dev/null +++ b/NOTICE @@ -0,0 +1,16 @@ +connvitals +Copyright 2018 Comcast Cable Communications Management, LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +This product includes software developed at Comcast (http://www.comcast.com/). diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdd5c17 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# connvitals +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +Checks a machines 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. +*Note: Does not recognize duplicate hosts passed on `argv` and will test each as though unique.* +*Note: Under normal execution conditions, requires super-user privileges to run.* + +## Dependencies +The utility runs on Python 3 (tested 3.6.3), but requires no non-standard external modules. + +## Installation +### Binary packages +Binary packages are offered in `.rpm` format for Fedora/CentOS/RHEL and `.whl` format for all other operating systems under '[Releases](https://github.com/connvitals/releases)'. + +### From This Repository with `pip` +The easiest way to install is to simply use `pip`. You can install directly from this repository without needing to manually download it by running +```bash +user@hostname ~ $ pip install git+https://github.com/connvitals.git#egg=connvitals +``` +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`. + +### Manually +To install manually, first download or clone this repository. Then, in the directory you downloaded/cloned it into, run the command +```bash +user@hostname ~/connvitals $ python setup.py install +``` +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. +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). + +## Usage +```bash +connvitals [ -h --help ] [ -V --version ] [ -H --hops HOPS ] [ -p --pings PINGS ] [ -P --no-ping ] [ -t --trace ] [ --payload-size PAYLOAD ] [ -s --port-scan ] host [ hosts... ] +``` + +* `hosts` - The host or hosts to check connection to. May be ipv4 addresses, ipv6 addresses, fqdn's, or any combination thereof. +* `-h` or `--help` - Prints help text, then exits successfully. +* `-V` or `--version` - Prints the program's version information, then exits successfully. +* `-H` or `--hops` - Sets max hops for route tracing (default 30). +* `-p` or `--pings` - Sets the number of pings to use for aggregate statistics (default 4). +* `-P` or `--no-ping` - Don't run ping tests. +* `-t` or `--trace` - Run route tracing. +* `-j` or `--json` - Prints output as one line of JSON-formatted text. +* `-s` or `--port-scan` - Perform a limited scan on each hosts' ports. +* `--payload-size` - Sets the size (in B) of ping packet payloads (default 41). + +### Output Format + +#### Normal Output +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)`. + +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. + +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. + +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. + +Example Output (with localhost running mysql server): + +```bash +root@hostname / # connvitals -stp 100 google.com 2607:f8b0:400f:807::200e localhost +google.com (172.217.3.14) +3.543 4.955 11.368 1.442 0.000 +10.169.240.1 3.108 +10.168.253.8 2.373 +10.168.254.252 3.659 +10.168.255.226 2.399 +198.178.8.94 3.059 +69.241.22.33 51.104 +68.86.103.13 16.470 +68.86.92.121 5.488 +68.86.86.77 4.257 +68.86.83.6 3.946 +173.167.58.142 5.290 +* +216.239.49.247 4.491 +172.217.3.14 3.927 +56.446, 200, gws 75.599, 200, gws None +2607:f8b0:400f:807::200e +3.446 4.440 12.422 1.526 0.000 +2001:558:1418:49::1 8.846 +2001:558:3da:74::1 1.453 +2001:558:3da:6f::1 2.955 +2001:558:3da:1::2 2.416 +2001:558:3c2:15::1 2.605 +2001:558:fe1c:6::1 47.516 +2001:558:1c0:65::1 45.442 +2001:558:0:f71e::1 9.165 +* +* +2001:559:0:9::6 3.984 +* +2001:4860:0:1::10ad 3.970 +2607:f8b0:400f:807::200e 3.891 +57.706, 200, gws 77.736, 200, gws None +localhost (127.0.0.1) +0.045 0.221 0.665 0.112 1.000 +127.0.0.1 0.351 +None None 0.165, 5.7.2 +``` + +#### JSON Output Format +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'`. +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. +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. +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: + * `'rtt'` - the time taken for the server to respond + * `'response code'` - The decimal representation of the server's response code to a `HEAD / HTML/1.1` request. + * `'server'` - the name of the server, if found within the first kilobyte of the server's response, otherwise "Unknown". +`'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. +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. + +Example JSON Output (with localhost running mysql server): +```bash +root@hostname / # sudo connvitals -j --port-scan -tp 100 google.com 2607:f8b0:400f:807::200e localhost +``` +```json +{"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"}}} +{"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"}}} +{"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"}}}} + +``` + +#### Error Output Format +When an error occurs, it is printed to `stderr` in the following format: +``` +EE: : : - +``` +`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`. + +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: +``` +WW: - +``` +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. + +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). diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..382cb41 --- /dev/null +++ b/README.rst @@ -0,0 +1,275 @@ +connvitals +========== + +|License| + +| Checks a machines 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. +| *Note: Does not recognize duplicate hosts passed on ``argv`` and will + test each as though unique.* +| *Note: Under normal execution conditions, requires super-user + privileges to run.* + +Dependencies +------------ + +The utility runs on Python 3 (tested 3.6.3), but requires no +non-standard external modules. + +Installation +------------ + +Binary packages +~~~~~~~~~~~~~~~ + +Binary packages are offered in ``.rpm`` format for Fedora/CentOS/RHEL +and ``.whl`` format for all other operating systems under +'`Releases `__'. + +From This Repository with ``pip`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The easiest way to install is to simply use ``pip``. You can install +directly from this repository without needing to manually download it by +running + +.. code:: bash + + user@hostname ~ $ pip install git+https://github.com/connvitals.git#egg=connvitals + +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``. + +Manually +~~~~~~~~ + +To install manually, first download or clone this repository. Then, in +the directory you downloaded/cloned it into, run the command + +.. code:: bash + + user@hostname ~/connvitals $ python setup.py install + +| 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. +| 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). + +Usage +----- + +.. code:: bash + + connvitals [ -h --help ] [ -V --version ] [ -H --hops HOPS ] [ -p --pings PINGS ] [ -P --no-ping ] [ -t --trace ] [ --payload-size PAYLOAD ] [ -s --port-scan ] host [ hosts... ] + +- ``hosts`` - The host or hosts to check connection to. May be ipv4 + addresses, ipv6 addresses, fqdn's, or any combination thereof. +- ``-h`` or ``--help`` - Prints help text, then exits successfully. +- ``-V`` or ``--version`` - Prints the program's version information, + then exits successfully. +- ``-H`` or ``--hops`` - Sets max hops for route tracing (default 30). +- ``-p`` or ``--pings`` - Sets the number of pings to use for aggregate + statistics (default 4). +- ``-P`` or ``--no-ping`` - Don't run ping tests. +- ``-t`` or ``--trace`` - Run route tracing. +- ``-j`` or ``--json`` - Prints output as one line of JSON-formatted + text. +- ``-s`` or ``--port-scan`` - Perform a limited scan on each hosts' + ports. +- ``--payload-size`` - Sets the size (in B) of ping packet payloads + (default 41). + +Output Format +~~~~~~~~~~~~~ + +Normal Output +^^^^^^^^^^^^^ + +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)``. + +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. + +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. + +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. + +Example Output (with localhost running mysql server): + +.. code:: bash + + root@hostname / # connvitals -stp 100 google.com 2607:f8b0:400f:807::200e localhost + google.com (172.217.3.14) + 3.543 4.955 11.368 1.442 0.000 + 10.169.240.1 3.108 + 10.168.253.8 2.373 + 10.168.254.252 3.659 + 10.168.255.226 2.399 + 198.178.8.94 3.059 + 69.241.22.33 51.104 + 68.86.103.13 16.470 + 68.86.92.121 5.488 + 68.86.86.77 4.257 + 68.86.83.6 3.946 + 173.167.58.142 5.290 + * + 216.239.49.247 4.491 + 172.217.3.14 3.927 + 56.446, 200, gws 75.599, 200, gws None + 2607:f8b0:400f:807::200e + 3.446 4.440 12.422 1.526 0.000 + 2001:558:1418:49::1 8.846 + 2001:558:3da:74::1 1.453 + 2001:558:3da:6f::1 2.955 + 2001:558:3da:1::2 2.416 + 2001:558:3c2:15::1 2.605 + 2001:558:fe1c:6::1 47.516 + 2001:558:1c0:65::1 45.442 + 2001:558:0:f71e::1 9.165 + * + * + 2001:559:0:9::6 3.984 + * + 2001:4860:0:1::10ad 3.970 + 2607:f8b0:400f:807::200e 3.891 + 57.706, 200, gws 77.736, 200, gws None + localhost (127.0.0.1) + 0.045 0.221 0.665 0.112 1.000 + 127.0.0.1 0.351 + None None 0.165, 5.7.2 + +JSON Output Format +^^^^^^^^^^^^^^^^^^ + +| 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'``. +| 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. +| 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. +| 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: +| \* ``'rtt'`` - the time taken for the server to respond +| \* ``'response code'`` - The decimal representation of the server's + response code to a ``HEAD / HTML/1.1`` request. +| \* ``'server'`` - the name of the server, if found within the first + kilobyte of the server's response, otherwise "Unknown". +| ``'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. +| 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. + +Example JSON Output (with localhost running mysql server): + +.. code:: bash + + root@hostname / # sudo connvitals -j --port-scan -tp 100 google.com 2607:f8b0:400f:807::200e localhost + +.. code:: json + + {"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"}}} + {"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"}}} + {"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"}}}} + +Error Output Format +^^^^^^^^^^^^^^^^^^^ + +When an error occurs, it is printed to ``stderr`` in the following +format: + +:: + + EE: : : - + +``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``. + +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: + +:: + + WW: - + +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. + +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). + +.. |License| image:: https://img.shields.io/badge/License-Apache%202.0-blue.svg + :target: https://opensource.org/licenses/Apache-2.0 diff --git a/connvitals/__init__.py b/connvitals/__init__.py new file mode 100644 index 0000000..7c5a314 --- /dev/null +++ b/connvitals/__init__.py @@ -0,0 +1,75 @@ +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +A utility to check connection vitals with a remote host. + + Usage: connvitals [ -h --help ] [ -H --hops HOPS ] [ -p --pings PINGS ] [ -P --no-ping ] + [ -t --trace ] [ --payload-size PAYLOAD ] [ --port-scan ] [ -j --json ] + host [hosts... ] + +Each 'host' can be an ipv4 address, ipv6 address, or a fully-qualified domain name. + +Submodules: + utils: Contains utility functionality such as error/warning reporting and host address parsing + ping: Groups functionality related to ICMP/ICMPv6 tests + traceroute: Contains a function for tracing a route to a host + ports: Specifies functions for checking specific host ports for http(s) and MySQL servers + +""" + +__version__ = "4.0.1" +__author__ = "Brennan Fieck" + +def main() -> int: + """ + Runs the utility with the arguments specified on sys.argv. + Returns: Always 0 to indicate "Success", unless the utility terminates + prematurely with a fatal error. + """ + from . import utils + from . import config + from . import collector + + config.init() + + # No hosts could be parsed + if not config.HOSTS: + utils.error("No hosts could be parsed! Exiting...", True) + + collectors = [collector.Collector(host) for host in config.HOSTS] + + # Start all the collectors + for collect in collectors: + collect.start() + + # Wait for every collector to finish + # Print JSON if requested + if config.JSON: + for collect in collectors: + _ = collect.join() + collect.result = collect.recv() + print(repr(collect)) + + # ... else print plaintext + else: + for collect in collectors: + _ = collect.join() + collect.result = collect.recv() + print(collect) + + + # Errors will be indicated on stdout; because we query multiple hosts, as + # long as the main routine doesn't crash, we have exited successfully. + return 0 diff --git a/connvitals/collector.py b/connvitals/collector.py new file mode 100644 index 0000000..0e42ee7 --- /dev/null +++ b/connvitals/collector.py @@ -0,0 +1,158 @@ +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module defines a single worker to collect stats from a single host""" + +import multiprocessing +import math +from . import utils, config, ping, traceroute, ports + +class Collector(multiprocessing.Process): + """ + A threaded worker that collects stats for a single host. + """ + trace = None + result = [utils.PingResult(-1, -1, -1, -1, 100.), + utils.Trace([utils.TraceStep('*', -1)] * 10), + utils.ScanResult(None, None, None)] + + def __init__(self, host: str): + """ + Initializes the Collector, and its worker pool + """ + super(Collector, self).__init__() + + self.hostname = host + self.host = config.HOSTS[host] + self.name = host + + self.pipe = multiprocessing.Pipe() + + def run(self): + """ + Called when the thread is run + """ + with multiprocessing.pool.ThreadPool() as pool: + pscan_result, trace_result, ping_result = None, None, None + if config.PORTSCAN: + pscan_result = pool.apply_async(ports.portScan, + (self.host, pool), + error_callback=utils.error) + if config.TRACE: + trace_result = pool.apply_async(traceroute.trace, + (self.host,), + error_callback=utils.error) + if not config.NOPING: + try: + self.ping(pool) + except (multiprocessing.TimeoutError, ValueError): + self.result[0] = type(self).result[0] + if config.TRACE: + try: + self.result[1] = trace_result.get(config.HOPS) + except multiprocessing.TimeoutError: + self.result[1] = type(self).result[1] + if config.PORTSCAN: + try: + self.result[2] = pscan_result.get(0.5) + except multiprocessing.TimeoutError: + self.result[2] = type(self).result[2] + + self.pipe[1].send(self.result) + + def ping(self, pool: multiprocessing.pool.ThreadPool): + """ + Pings the host + """ + pinger = ping.Pinger(self.host, bytes(config.PAYLOAD)) + + # Aggregates round-trip time for each packet in the sequence + rtt, lost = [], 0 + + # Sends, receives and parses all icmp packets asynchronously + results = pool.map_async(pinger.ping, + range(config.NUMPINGS), + error_callback=utils.error) + pkts = results.get(8) + pinger.sock.close() + del pinger + + for pkt in pkts: + if pkt != None and pkt > 0: + rtt.append(pkt*1000) + else: + lost += 1 + + try: + avg = sum(rtt) / len(rtt) + std = 0. + for item in rtt: + std += (avg - item)**2 + std /= len(rtt) - 1 + std = math.sqrt(std) + except ZeroDivisionError: + std = 0. + + self.result[0] = utils.PingResult(min(rtt), avg, max(rtt), std, lost/config.NUMPINGS *100.0) + + def __str__(self) -> str: + """ + Implements 'str(self)' + + Returns a plaintext output result + """ + ret = [] + if self.host[0] == self.hostname: + ret.append(self.hostname) + else: + ret.append("%s (%s)" % (self.hostname, self.host[0])) + + pings, trace, scans = self.result + + if pings: + ret.append(str(pings)) + if trace and trace != self.trace: + self.trace = trace + ret.append(str(trace)) + if scans: + ret.append(str(scans)) + + return "\n".join(ret) + + def __repr__(self) -> repr: + """ + Implements `repr(self)` + + Returns a JSON output result + """ + ret = [r'{"addr":"%s"' % self.host[0]] + ret.append(r'"name":"%s"' % self.hostname) + + if not config.NOPING: + ret.append(r'"ping":%s' % repr(self.result[0])) + + if config.TRACE and self.trace != self.result[1]: + self.trace = self.result[1] + ret.append(r'"trace":%s' % repr(self.result[1])) + + if config.PORTSCAN: + ret.append(r'"scan":%s' % repr(self.result[2])) + + return ','.join(ret) + '}' + + def recv(self): + """ + Returns a message from the Collector's Pipe + """ + return self.pipe[0].recv() diff --git a/connvitals/config.py b/connvitals/config.py new file mode 100644 index 0000000..9f60b9c --- /dev/null +++ b/connvitals/config.py @@ -0,0 +1,117 @@ +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module defines the config options for the 'connvitals' command +""" + + +from . import utils + +# connvitals version +__version__ = "4.0.1" + +# Configuration values +HOPS = 30 +JSON = False +PAYLOAD = b'The very model of a modern Major General.' +TRACE = False +NOPING = False +PORTSCAN = False +NUMPINGS = 10 +HOSTS = {} + +def init(): + """ + Initializes the configuration. + """ + global HOPS, JSON, PAYLOAD, TRACE, NOPING, PORTSCAN, NUMPINGS, HOSTS, __version__ + + from argparse import ArgumentParser as Parser + parser = Parser(description="A utility to check connection vitals with a remote host.", + epilog="'host' can be an ipv4 or ipv6 address, or a fully-qualified domain name.") + + parser.add_argument("hosts", + help="The host or hosts to check connection to. "\ + "These can be ipv4 addresses, ipv6 addresses, fqdn's, "\ + "or any combination thereof.", + nargs="+") + + parser.add_argument("-H", "--hops", + dest="hops", + help="Sets max hops for route tracing (default 30).", + default=30, + type=int) + + parser.add_argument("-p", "--pings", + dest="numpings", + help="Sets the number of pings to use for aggregate statistics (default 10).", + default=10, + type=int) + + parser.add_argument("-P", "--no-ping", + dest="noping", + help="Don't run ping tests.", + action="store_true") + + parser.add_argument("-t", "--trace", + dest="trace", + help="Run route tracing.", + action="store_true") + + parser.add_argument("-s", "--port-scan", + dest="portscan", + help="Scan the host(s)'s ports for commonly-used services", + action="store_true") + + parser.add_argument("--payload-size", + dest="payload", + help="Sets the size (in B) of ping packet payloads (default 41).", + default=b'The very model of a modern Major General.', + type=int) + + parser.add_argument("-j", "--json", + dest="json", + help="Outputs in machine-readable JSON (no newlines)", + action="store_true") + + parser.add_argument("-V", "--version", + dest="version", + help="Print the program's version, then exit.", + action="store_true") + + args = parser.parse_args() + + if args.version: + print("python3-connvitals Version %s" % __version__) + exit(0) + + HOPS = args.hops + JSON = args.json + PAYLOAD = args.payload + TRACE = args.trace + NOPING = args.noping + PORTSCAN = args.portscan + NUMPINGS = args.numpings + hosts = args.hosts + + # Parse the list of hosts and try to find valid addresses for each + HOSTS = {} + + for host in hosts: + info = utils.getaddr(host) + if not info: + utils.error("Unable to resolve host ( %s )" % host) + else: + HOSTS[host] = info diff --git a/connvitals/ping.py b/connvitals/ping.py new file mode 100644 index 0000000..aadb0a9 --- /dev/null +++ b/connvitals/ping.py @@ -0,0 +1,295 @@ +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module defines a class and utilities to manage icmp/icmpv6 echo requests +and replies to remote hosts. +""" + +import socket +import struct +import time +import sys +from . import utils + + +def calculate_checksum(pkt: bytes) -> bytes: + """ + Implementation of the "Internet Checksum" specified in + RFC 1071 (https://tools.ieft.org/html/rfc1071) + + Ideally this would act on the string as a series of half-words in host byte order, + but this works. + + Network data is big-endian, hosts are typically little-endian, + which makes this much more tedious than it needs to be. + """ + + from sys import byteorder + + countTo = len(pkt) // 2 * 2 + total, count = 0, 0 + + # Handle bytes in pairs (decoding as short ints) + loByte, hiByte = 0, 0 + while count < countTo: + if byteorder == "little": + loByte = pkt[count] + hiByte = pkt[count + 1] + else: + loByte = pkt[count + 1] + hiByte = pkt[count] + total += hiByte * 256 + loByte + count += 2 + + # Handle last byte if applicable (odd-number of bytes) + # Endianness should be irrelevant in this case + if countTo < len(pkt): # Check for odd length + total += pkt[len(pkt) - 1] + + total &= 0xffffffff # Truncate sum to 32 bits (a variance from ping.c, which + # uses signed ints, but overflow is unlikely in ping) + + total = (total >> 16) + (total & 0xffff) # Add high 16 bits to low 16 bits + total += (total >> 16) # Add carry from above (if any) + + return socket.htons((~total) & 0xffff) + +def IPv6_checksum(pkt: bytes, laddr: bytes, raddr: bytes) -> bytes: + """ + Implementation of the ICMPv6 "Internet Checksum" as specified in + RFC 1701 (https://tools.ieft.org/html/rfc1701). + + This takes the Payload Length from the IPv6 layer to be 32 (0x20), since we + don't expect any extension headers and ICMP doesn't carry any length + information. + pkt: A complete ICMP packet, with the checksum field set to 0 + laddr: The (fully-expanded) local address of the socket that will send pkt + raddr: The (fully-expanded) remote address of the host to which the pkt will be sent + returns: A bytes object representing the checksum + """ + + # IPv6 Pseudo-Header used for checksum calculation as specified by + # RFC 2460 (https://tools.ieft.org/html/rfc2460) + psh = laddr + raddr + struct.pack("!I", len(pkt)) + b'\x00\x00\x00:' + # This last bit is the 4-byte-packed icmp6 protocol number (58 or 0xa3) + + + total, packet = 0, psh+pkt + + # Sum all 2-byte words + num_words = len(packet) // 2 + for chunk in struct.unpack("!%sH" % num_words, packet[0:num_words*2]): + total += chunk + + # Add any left-over byte (for odd-length packets) + if len(packet) % 2: + total += ord(packet[-1]) << 8 + + # Fold 32-bits into 16-bits + total = (total >> 16) + (total & 0xffff) + total += total >> 16 + return ~total + 0x10000 & 0xffff + +def icmpParse(pkt: bytes, ipv6: bool) -> int: + """ + Parses an icmp packet, returning its sequence number. + + If the packet is found to be not an echo reply, this will + immediately return -1, indicating that this packet + should be disregarded. + """ + try: + if ipv6: + if pkt[0] == 129: + return struct.unpack("!H", pkt[6:8])[0] + return -1 + if pkt[20] == 0: + return struct.unpack("!H", pkt[26:28])[0] + return -1 + except (IndexError, struct.error): + return -1 + +class Pinger(object): + """ + A data structure that handles icmp pings to a remote machine. + """ + def __init__(self, host: utils.Host, payload: bytes): + """ + Inializes a socket connection to the host on port 22, and returns a Pinger object + referencing it. + """ + + self.sock, self.icmpParse, self.mkPkt = None, None, None + + if host[1] == socket.AF_INET6: + self.sock = socket.socket(host[1], socket.SOCK_RAW, proto=58) + self.icmpParse = self._icmpv6Parse + self.mkPkt = self._mkPkt6 + else: + self.sock = socket.socket(host[1], socket.SOCK_RAW, proto=1) + self.icmpParse = self._icmpv4Parse + self.mkPkt = self._mkPkt4 + + self.sock.settimeout(2) + self.payload = payload + + #Build a socket object + self.host = host + + self.timestamps = {} + + def ping(self, seqno: int) -> float: + """ + Sends a single icmp packet to the remote host. + Returns the round-trip time (in ms) between packet send and receipt + or 0 if packet was not received. + """ + pkt = self.mkPkt(seqno) + + # I set time here so that rtt includes the device latency + self.timestamps[seqno] = time.time() + + try: + # ICMP has no notion of port numbers + self.sock.sendto(pkt, (self.host[0], 1)) + except Exception as e: + #Sometimes, when the network is unreachable this will erroneously report that there's an + #'invalid argument', which is impossible since the hostnames are coming straight from + #`socket` itself + raise Exception("Network is unreachable... (%s)" % e) + return self.recv() + + @staticmethod + def _icmpv4Parse(pkt: bytes) -> int: + """ + Attemtps to parse an icmpv4 packet, returning the sequence number if parsing succeds, + or -1 otherwise. + """ + try: + if pkt[20] == 0: + return struct.unpack("!H", pkt[26:28])[0] + except (IndexError, struct.error): + pass + return -1 + + @staticmethod + def _icmpv6Parse(pkt: bytes) -> int: + """ + Attemtps to parse an icmpv6 packet, returning the sequence number if parsing succeds, + or -1 otherwise. + """ + try: + if pkt[0] == 0x81: + return struct.unpack("!H", pkt[6:8])[0] + except (IndexError, struct.error): + pass + return -1 + + def _mkPkt4(self, seqno: int) -> bytes: + """ + Contsructs and returns an ICMPv4 packet + """ + header = struct.pack("!BBHHH", 8, 0, 0, 2, seqno) + checksum = self._checksum4(header + self.payload) + return struct.pack("!BBHHH", 8, 0, checksum, 2, seqno) + self.payload + + def _mkPkt6(self, seqno: int) -> bytes: + """ + Contsructs and returns an ICMPv6 packet + """ + header = struct.pack("!BBHHH", 0x80, 0, 0, 2, seqno) + checksum = self._checksum6(header) + return struct.pack("!BBHHH", 0x80, 0, checksum, 2, seqno) + self.payload + + @staticmethod + def _checksum4(pkt: bytes) -> int: + """ + calculates and returns the icmpv4 checksum of 'pkt' + """ + + countTo = len(pkt) // 2 * 2 + total, count = 0, 0 + + # Handle bytes in pairs (decoding as short ints) + loByte, hiByte = 0, 0 + while count < countTo: + if sys.byteorder == "little": + loByte = pkt[count] + hiByte = pkt[count + 1] + else: + loByte = pkt[count + 1] + hiByte = pkt[count] + total += hiByte * 256 + loByte + count += 2 + + # Handle last byte if applicable (odd-number of bytes) + # Endianness should be irrelevant in this case + if countTo < len(pkt): # Check for odd length + total += pkt[len(pkt) - 1] + + total &= 0xffffffff # Truncate sum to 32 bits (a variance from ping.c, which + # uses signed ints, but overflow is unlikely in ping) + + total = (total >> 16) + (total & 0xffff) # Add high 16 bits to low 16 bits + total += (total >> 16) # Add carry from above (if any) + + return socket.htons((~total) & 0xffff) + + def _checksum6(self, pkt: bytes) -> int: + """ + calculates and returns the icmpv6 checksum of pkt + """ + laddr = socket.inet_pton(self.host[1], self.sock.getsockname()[0]) + raddr = socket.inet_pton(*reversed(self.host)) + # IPv6 Pseudo-Header used for checksum calculation as specified by + # RFC 2460 (https://tools.ieft.org/html/rfc2460) + psh = laddr + raddr + struct.pack("!I", len(pkt)) + b'\x00\x00\x00:' + # This last bit is the 4-byte-packed icmp6 protocol number (58 or 0xa3) + + + total, packet = 0, psh+pkt + + # Sum all 2-byte words + num_words = len(packet) // 2 + for chunk in struct.unpack("!%sH" % num_words, packet[0:num_words*2]): + total += chunk + + # Add any left-over byte (for odd-length packets) + if len(packet) % 2: + total += ord(packet[-1]) << 8 + + # Fold 32-bits into 16-bits + total = (total >> 16) + (total & 0xffff) + total += total >> 16 + return ~total + 0x10000 & 0xffff + + def recv(self) -> float: + """ + Recieves each ping sent. + """ + # If a packet is not an echo reply, icmpParse will give its seqno as -1 + # This lets us disregard packets from traceroutes immediately + while True: + + try: + pkt, addr = self.sock.recvfrom(100+len(self.payload)) + except socket.timeout: + return -1 + + # The packet must have actually come from the host we pinged + if addr[0] == self.host[0]: + seqno = self.icmpParse(pkt) + if seqno >= 0: + return time.time() - self.timestamps[seqno] diff --git a/connvitals/ports.py b/connvitals/ports.py new file mode 100644 index 0000000..c4b2c1d --- /dev/null +++ b/connvitals/ports.py @@ -0,0 +1,107 @@ +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module contains functions for scanning some specific hosts for specific +information. Currently has functionality for http(s) servers on ports 80/443 +and MySQL servers on port 3306. +""" + +import socket +import time +import multiprocessing.pool +import typing +import ssl +from . import utils + +def http(url: utils.Host, port: int=80) -> typing.Optional[typing.Tuple[float, str, str]]: + """ + Checks for http content being served by url on a port passed in ssl. + (If ssl is 443, wraps the socket with ssl to communicate HTTPS) + Returns a HEAD request's status code if a server is found, else None + """ + + # Create socket (wrap for ssl as needed) + sock = socket.socket(family=url[1]) + if port == 443: + sock = ssl.wrap_socket(sock, ssl_version=3) + sock.settimeout(0.08) + + # Send request, and return "None" if anything goes wrong + try: + rtt = time.time() + sock.connect((url[0], port)) + sock.send(b"HEAD / HTTP/1.1\r\n\r\n") + ret = sock.recv(1000) + rtt = time.time() - rtt + except (OSError, ConnectionRefusedError, socket.gaierror, socket.timeout) as e: + utils.error(Exception("Could not connect to %s: %s" % (url[0], e))) + return None + except ssl.SSLError as e: + utils.warn("SSL handshake with %s failed: %s" % (url[0], e)) + return None + finally: + sock.close() + + # Servers that enforce ssl encryption when our socket isn't wrapped - or don't + # recognize encrypted requests when it is - will sometimes send empty responses + if not ret: + return None + + # Check for "Server" header if available. + # Note - this assumes that both the contents of the "Server" header and the response code are + # utf8-decodable, which may need to be patched in the future + try: + srv = ret.index(b'Server: ') + except ValueError: + return rtt*1000, ret[9:12].decode(), "Unkown" + return rtt*1000, ret[9:12].decode(), ret[srv+8:ret.index(b'\r', srv)].decode() + + +def mysql(url: utils.Host) -> typing.Optional[typing.Tuple[float, str]]: + """ + Checks for a MySQL server running on the host specified by url. + Returns the server version if one is found, else None. + """ + + sock = socket.socket(family=url[1]) + sock.settimeout(0.08) + try: + rtt = time.time() + sock.connect((url[0], 3306)) + return (time.time() - rtt)* 1000, sock.recv(1000)[5:10].decode() + except (UnicodeError, OSError, ConnectionRefusedError, socket.gaierror, socket.timeout) as e: + utils.error(Exception("Could not connect to %s: %s" % (url[0], e))) + return None + finally: + sock.close() + +def portScan(host:utils.Host, pool:multiprocessing.pool.Pool)-> typing.Tuple[str, utils.ScanResult]: + """ + Scans a host using a multiprocessing worker pool to see if a specific set of ports are open, + possibly returning extra information in the case that they are. + + Returns a tuple of (host, information) where host is the ip of the host scanned and information + is any and all information gathered from each port as a tuple in the order (80, 443). + If the specified port is not open, its spot in the tuple will contain `None`, but will otherwise + contain some information related to the port. + """ + + # Dispatch the workers + hypertext = pool.apply_async(http, (host,)) + https = pool.apply_async(http, (host, 443)) + mysqlserver = pool.apply_async(mysql, (host,)) + + # Collect and return + return utils.ScanResult(hypertext.get(), https.get(), mysqlserver.get()) diff --git a/connvitals/traceroute.py b/connvitals/traceroute.py new file mode 100644 index 0000000..ac99f8e --- /dev/null +++ b/connvitals/traceroute.py @@ -0,0 +1,84 @@ +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module defines a single function which implements route tracing. +""" + +import socket +import time +from . import utils, config + +def trace(host: utils.Host) -> utils.Trace: + """ + Traces a route from the localhost to a given destination. + Returns a tabular list of network hops up to the maximum specfied by 'hops' + """ + ret = [] + + ipv6 = host[1] == socket.AF_INET6 + + receiver = socket.socket(family=host[1], type=socket.SOCK_RAW, proto=58 if ipv6 else 1) + receiver.settimeout(0.05) + sender = socket.socket(family=host[1], type=socket.SOCK_DGRAM, proto=17) + + # Sets up functions used in the main loop, so it can transparently + # handle ipv4 and ipv6 without needing to check which one we're + # using on every iteration. + setTTL, isTraceResponse, getIntendedDestination = None, None, None + if ipv6: + setTTL = lambda x: sender.setsockopt(41, 4, x) + isTraceResponse = lambda x: x[0] in {1, 3} + getIntendedDestination = lambda x: socket.inet_ntop(socket.AF_INET6, x[32:48]) + else: + setTTL = lambda x: sender.setsockopt(socket.SOL_IP, socket.IP_TTL, x) + isTraceResponse = lambda x: x[20] in {11, 3} + getIntendedDestination = lambda x: ".".join(str(byte) for byte in x[44:48]) + + for ttl in range(config.HOPS): + setTTL(ttl+1) + timestamp = time.time() + + try: + sender.sendto(b'', (host[0], 33440)) + except OSError as e: + ret.append(utils.TraceStep("*", -1)) + continue + + try: + #Wait for packets sent by this trace + while True: + pkt, addr = receiver.recvfrom(1024) + rtt = time.time() - timestamp + + # If this is a response from a tracer and the tracer sent + # it to the same place we're sending things, then this + # packet must belong to us. + if isTraceResponse(pkt): + destination = getIntendedDestination(pkt) + if destination == host[0]: + break + + except socket.timeout: + ret.append(utils.TraceStep("*", -1)) + done = False + else: + ret.append(utils.TraceStep(addr[0], rtt*1000)) + done = addr[0] == host[0] + + if done: + break + receiver.close() + sender.close() + return utils.Trace(ret) diff --git a/connvitals/utils.py b/connvitals/utils.py new file mode 100644 index 0000000..1df54bf --- /dev/null +++ b/connvitals/utils.py @@ -0,0 +1,189 @@ +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module contains utility functions used by the main utility to do things +lke printing errors and warnings to stderr, or get a single, valid IP address +for a host. +""" + +import typing +import socket + +# I don't know why, but pylint seems to think that socket.AddressFamily isn't real, but it is. +# Nobody else has this issue as far as I could find. +#pylint: disable=E1101 +Host = typing.NamedTuple("Host", [('addr', str), ('family', socket.AddressFamily)]) +#pylint: enable=E1101 + +PingResult = typing.NamedTuple("PingResult", [ + ('minimum', float), + ('avg', float), + ('maximum', float), + ('std', float), + ('loss', float)]) + +def pingResultToStr(self: PingResult) -> str: + """ + Returns the string representation of a ping result in plaintext + """ + fmt = "%.3f\t%.3f\t%.3f\t%.3f\t%.3f" + return fmt % (self.minimum, self.avg, self.maximum, self.std, self.loss) + +def pingResultRepr(self: PingResult) -> str: + """ + Returns the JSON representation of a ping result + """ + fmt = '{"min":%f,"avg":%f,"max":%f,"std":%f,"loss":%f}' + return fmt % (self.minimum, self.avg, self.maximum, self.std, self.loss) + +PingResult.__str__ = pingResultToStr +PingResult.__repr__ = pingResultRepr + + +TraceStep = typing.NamedTuple("TraceStep", [("host", str), ("rtt", float)]) +Trace = typing.NewType("Trace", typing.List[TraceStep]) + +def traceStepToStr(self: TraceStep) -> str: + """ + Returns the string representation of a step of a route trace in plaintext + """ + if self.rtt < 0 or self.host == "*": + return "*" + return "%s\t%.3f" % (self.host, self.rtt) + +def traceStepRepr(self: TraceStep) -> str: + """ + Returns the JSON representation of a single step in a route trace + """ + if self.rtt < 0 or self.host == "*": + return '["*"]' + return '["%s", %f]' % (self.host, self.rtt) + +def compareTraceSteps(self: TraceStep, other: TraceStep) -> bool: + """ + Implements `self == other` + + Two trace steps are considered equal iff their hosts are the same - rtt is not considered. + """ + return self.host == other.host + +def traceStepIsValid(self: TraceStep): + """ + Implements `bool(self)` + + Returns True if the step reports that the packet reached the host within the timeout, + False otherwise. + """ + return self.rtt >= 0 and self.host != "*" + +TraceStep.__str__ = traceStepToStr +TraceStep.__repr__ = traceStepRepr +TraceStep.__eq__ = compareTraceSteps +TraceStep.__bool__ = traceStepIsValid + +def compareTraces(self: Trace, other: Trace) -> bool: + """ + Implements `self == other` + + Checks that traces are of the same length and contain the same hosts in the same order + i.e. does *not* check the rtts of any or all trace steps. + + Note: ignores steps that are invalid ('*'). + """ + this, that = [step for step in self if step], [step for step in other if step] + return len(this) == len(that) and all(this[i] == that[i] for i in range(len(this))) + +def traceToStr(self: Trace) -> str: + """ + Implements `str(self)` + + Returns the plaintext representation of a route trace. + """ + return '\n'.join(str(step) for step in self) + +def traceRepr(self: Trace) -> str: + """ + Implements `repr(self)` + + Returns the JSON representation of a route trace. + """ + return "[%s]" % ','.join(repr(step) for step in self) + +Trace.__str__ = traceToStr +Trace.__repr__ = traceRepr +Trace.__eq__ = compareTraces + + +ScanResult = typing.NamedTuple("ScanResult", [("httpresult", typing.Tuple[float, str, str]), + ("httpsresult", typing.Tuple[float, str, str]), + ("mysqlresult", typing.Tuple[float, str])]) + +def scanResultToStr(self: ScanResult) -> str: + """ + Returns the string representation of a portscan result in plaintext + """ + return "%s\t%s\t%s" % ("%.3f, %s, %s" % self.httpresult if self.httpresult else 'None', + "%.3f, %s, %s" % self.httpsresult if self.httpsresult else 'None', + "%.3f, %s" % self.mysqlresult if self.mysqlresult else 'None') + +def scanResultRepr(self: ScanResult) -> str: + """ + Returns the JSON representation of a portscan result + """ + httpFmt = '{"rtt":%f,"response code":"%s","server":"%s"}' + http = httpFmt % self.httpresult if self.httpresult else '"None"' + https = httpFmt % self.httpsresult if self.httpsresult else '"None"' + mySQL = '{"rtt":%f,"version":"%s"}' % self.mysqlresult if self.mysqlresult else '"None"' + return '{"http":%s,"https":%s,"mysql":%s}' % (http, https, mySQL) + +ScanResult.__str__ = scanResultToStr +ScanResult.__repr__ = scanResultRepr + + +def error(err: Exception, fatal: int=False): + """ + Logs an error to stderr, then exits if fatal is a non-falsy value, using it as an exit code + """ + from sys import stderr + from time import ctime + if stderr.isatty(): + fmt = "\033[38;2;255;0;0mEE: %s:" + print(fmt % type(err).__name__, "%s" % err, "-\t", ctime(), "\033[m", file=stderr) + else: + print("EE: %s:" % type(err).__name__, "%s" % err, "-\t", ctime(), file=stderr) + if fatal: + exit(int(fatal)) + +def warn(warning: str): + """ + Logs a warning to stderr. + """ + from sys import stderr + from time import ctime + if stderr.isatty(): + print("\033[38;2;238;216;78mWW:", warning, "-\t", ctime(), "\033[m", file=stderr) + else: + print("WW:", warning, '-\t', ctime(), file=stderr) + +def getaddr(host: str) -> typing.Optional[Host]: + """ + Returns a tuple of Address Family, IP Address for the host passed in `host`. + """ + + try: + addrinfo = socket.getaddrinfo(host, 1).pop() + return Host(addrinfo[4][0], addrinfo[0]) + except socket.gaierror: + return None diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..e5efe50 --- /dev/null +++ b/setup.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# Copyright 2018 Comcast Cable Communications Management, LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A setuptools based setup module. +See: +https://packaging.python.org/en/latest/distributing.html +https://github.com/pypa/sampleproject +""" + +# To use a consistent encoding +import codecs +import os + +# RPMs generated for fedora/rhel/centos need to have a different name +# (debian/ubuntu automatically prepends python3-, but those do not) +import platform + +# Always prefer setuptools over distutils +from setuptools import setup + +pkgname = "connvitals" +if platform.linux_distribution(full_distribution_name=False)[0] in {'centos', 'fedora', 'redhat'}: + pkgname = "python3-"+pkgname + +here = os.path.abspath(os.path.dirname(__file__)) + +# Get the long description from the README file +with codecs.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: + long_description = f.read() + +# Arguments marked as "Required" below must be included for upload to PyPI. +# Fields marked as "Optional" may be commented out. + +setup( + # This is the name of your project. The first time you publish this + # package, this name will be registered for you. It will determine how + # users can install this project, e.g.: + # + # $ pip install sampleproject + # + # And where it will live on PyPI: https://pypi.org/project/sampleproject/ + # + # There are some restrictions on what makes a valid project name + # specification here: + # https://packaging.python.org/specifications/core-metadata/#name + name=pkgname, # Required + + # Versions should comply with PEP 440: + # https://www.python.org/dev/peps/pep-0440/ + # + # For a discussion on single-sourcing the version across setup.py and the + # project code, see + # https://packaging.python.org/en/latest/single_source_version.html + version='4.0.1', # Required + + # This is a one-line description or tagline of what your project does. This + # corresponds to the "Summary" metadata field: + # https://packaging.python.org/specifications/core-metadata/#summary + description='Checks a machines connection to a specific host or list of hosts', # Required + + # This is an optional longer description of your project that represents + # the body of text which users will see when they visit PyPI. + # + # Often, this is the same as your README, so you can just read it in from + # that file directly (as we have already done above) + # + # This field corresponds to the "Description" metadata field: + # https://packaging.python.org/specifications/core-metadata/#description-optional + long_description=long_description, # Optional + + # This should be a valid link to your project's main homepage. + # + # This field corresponds to the "Home-Page" metadata field: + # https://packaging.python.org/specifications/core-metadata/#home-page-optional + url='https://github.com/connvitals', # Optional + + # This should be your name or the name of the organization which owns the + # project. + author='Brennan Fieck', # Optional + + # This should be a valid email address corresponding to the author listed + # above. + author_email='Brennan_WilliamFieck@comcast.com', # Optional + + # Classifiers help users find your project by categorizing it. + # + # For a list of valid classifiers, see + # https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ # Optional + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 5 - Production/Stable', + + # Indicate who your project is intended for + 'Intended Audience :: Telecommunications Industry', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + + # Topic of the project + 'Topic :: Internet', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Scientific/Engineering :: Information Analysis', + 'Topic :: Utilities', + + # Pick your license as you wish + 'License :: Other/Proprietary License', + + # Environment in which this program is designed to run + 'Environment :: Console', + + # Supported Operating Systems + 'Operating Systems :: OS Independent', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy' + 'Programming Language :: Python :: 3 :: Only' + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7' + ], + + # This field adds keywords for your project which will appear on the + # project page. What does your project relate to? + # + # Note that this is a string of words separated by whitespace, not a list. + keywords='network statistics connection ping traceroute port ip', # Optional + + # You can just specify package directories manually here if your project is + # simple. Or you can use find_packages(). + # + # Alternatively, if you just want to distribute a single Python file, use + # the `py_modules` argument instead as follows, which will expect a file + # called `my_module.py` to exist: + # + # py_modules=["connvitals", "ping", "traceroute", "ports"], + # + packages=['connvitals'], # Required + + # This field lists other packages that your project depends on to run. + # Any package you put here will be installed by pip when your project is + # installed, so they must be valid existing projects. + # + # For an analysis of "install_requires" vs pip's requirements files see: + # https://packaging.python.org/en/latest/requirements.html + install_requires=['setuptools', 'typing'], # Optional + + # List additional groups of dependencies here (e.g. development + # dependencies). Users will be able to install these using the "extras" + # syntax, for example: + # + # $ pip install sampleproject[dev] + # + # Similar to `install_requires` above, these must be valid existing + # projects. + # extras_require={ # Optional + # 'dev': ['check-manifest'], + # 'test': ['coverage'], + # }, + + # If there are data files included in your packages that need to be + # installed, specify them here. + # + # If using Python 2.6 or earlier, then these have to be included in + # MANIFEST.in as well. + # package_data={ # Optional + # 'sample': ['package_data.dat'], + # }, + + # Although 'package_data' is the preferred approach, in some case you may + # need to place data files outside of your packages. See: + # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files + # + # In this case, 'data_file' will be installed into '/my_data' + # data_files=[('my_data', ['data/data_file'])], # Optional + + # To provide executable scripts, use entry points in preference to the + # "scripts" keyword. Entry points provide cross-platform support and allow + # `pip` to create the appropriate form of executable for the target + # platform. + # + # For example, the following would provide a command called `sample` which + # executes the function `main` from this package when invoked: + entry_points={ # Optional + 'console_scripts': [ + 'connvitals=connvitals.__init__:main', + ], + }, + + # Requires python version >= 3.4, but doesn't support python 4 + python_requires='~=3.4' +)