From 2bc83a94d60104cdc7048be19676fe7d5b435cd3 Mon Sep 17 00:00:00 2001 From: Daniel Carrillo Date: Fri, 25 Dec 2020 21:36:41 +0100 Subject: [PATCH] First commit --- .github/workflows/main.yml | 61 +++++++ .gitignore | 10 ++ LICENSE | 201 +++++++++++++++++++++ README.md | 99 +++++++++++ digaws/__init__.py | 2 + digaws/digaws.py | 257 +++++++++++++++++++++++++++ noxfile.py | 32 ++++ requirements.txt | 2 + requirements_test.txt | 6 + setup.py | 36 ++++ tests/__init__.py | 134 ++++++++++++++ tests/test_dig_aws.py | 77 ++++++++ tests/test_get_aws_ip_ranges_json.py | 87 +++++++++ 13 files changed, 1004 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 digaws/__init__.py create mode 100755 digaws/digaws.py create mode 100644 noxfile.py create mode 100644 requirements.txt create mode 100644 requirements_test.txt create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_dig_aws.py create mode 100644 tests/test_get_aws_ip_ranges_json.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..0c3643b --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [3.6, 3.7, 3.8, 3.9, pypy3] + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install nox + run: | + python -m pip install --upgrade pip + pip install nox + + - name: Lint and typing + run: | + nox -rs lint typing + + - name: Tests + run: | + nox -rs tests -- -v + + build_publish: + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install tools + run: | + python -m pip install --upgrade pip + pip install twine + + - name: Build + run: | + python setup.py sdist bdist_wheel + + - name: Publish + run: | + export TWINE_USERNAME=${{ secrets.TWINE_USERNAME }} + export TWINE_PASSWORD=${{ secrets.TWINE_PASSWORD }} + twine upload dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe15542 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +logs/ +__pycache__/ +.vscode/ +.nox/ +build/ +dist/ +*.egg-info/ +.coverage +.pytest_cache/ +.mypy_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6700d81 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +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 2020 Daniel Carrillo + + 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/README.md b/README.md index 519a934..74a2245 100644 --- a/README.md +++ b/README.md @@ -1 +1,100 @@ # digaws + +The digaws lookup tool displays information for a given IP address (v4 o v6) or a CIDR, sourced from the AWS official IP ranges. +In order to save bandwidth and time this tool requests the [AWS IP ranges](https://ip-ranges.amazonaws.com/ip-ranges.json) and keeps +a cached version until a new version is published. + +## Install + +```bash +pip install digaws +``` + +## Usage + +```bash +usage: digaws [-h] [--output ] [--debug] [ ...] + +Look up canonical information for AWS IP addresses and networks + +positional arguments: + CIDR or IP (v4 or v6) to look up + +optional arguments: + -h, --help show this help message and exit + --output + Formatting style for command output, by default plain + --debug Enable debug +``` + +## Examples + +- look up an IPv4 address + +```bash +~ » digaws 52.218.97.130 + +Prefix: 52.218.0.0/17 +Region: eu-west-1 +Service: AMAZON +Network border group: eu-west-1 + +Prefix: 52.218.0.0/17 +Region: eu-west-1 +Service: S3 +Network border group: eu-west-1 +``` + +- look up an IPv6 address + +```bash +~ » digaws 2600:1f1e:fff:f810:a29b:cb50:2812:e2dc + +IPv6 Prefix: 2600:1f1e::/36 +Region: sa-east-1 +Service: AMAZON +Network border group: sa-east-1 + +IPv6 Prefix: 2600:1f1e:fff:f800::/53 +Region: sa-east-1 +Service: ROUTE53_HEALTHCHECKS +Network border group: sa-east-1 + +IPv6 Prefix: 2600:1f1e::/36 +Region: sa-east-1 +Service: EC2 +Network border group: sa-east-1 +``` + +- look up several addresses and print output as json + +```bash +~ » digaws 2600:1f14::/36 13.224.119.88 --output json + +[ + { + "ipv6_prefix": "2600:1f14::/35", + "region": "us-west-2", + "service": "AMAZON", + "network_border_group": "us-west-2" + }, + { + "ipv6_prefix": "2600:1f14::/35", + "region": "us-west-2", + "service": "EC2", + "network_border_group": "us-west-2" + }, + { + "ip_prefix": "13.224.0.0/14", + "region": "GLOBAL", + "service": "AMAZON", + "network_border_group": "GLOBAL" + }, + { + "ip_prefix": "13.224.0.0/14", + "region": "GLOBAL", + "service": "CLOUDFRONT", + "network_border_group": "GLOBAL" + } +] +``` diff --git a/digaws/__init__.py b/digaws/__init__.py new file mode 100644 index 0000000..1b5e572 --- /dev/null +++ b/digaws/__init__.py @@ -0,0 +1,2 @@ +__version__ = '1.0' +__description__ = 'Look up canonical information for AWS IP addresses and networks' diff --git a/digaws/digaws.py b/digaws/digaws.py new file mode 100755 index 0000000..2ebb6c8 --- /dev/null +++ b/digaws/digaws.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 + +import argparse +import ipaddress +import json +import logging +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +from dateutil import tz + +import requests +from requests.exceptions import RequestException + +from . import __description__ +from . import __version__ + +AWS_IP_RANGES_URL = 'https://ip-ranges.amazonaws.com/ip-ranges.json' +CACHE_DIR = Path(Path.home() / '.digaws') +CACHE_FILE = CACHE_DIR / 'ip-ranges.json' + +logger = logging.getLogger() +handler = logging.StreamHandler(sys.stderr) +logger.addHandler(handler) +handler.setFormatter(logging.Formatter('-- %(levelname)s -- %(message)s')) +logger.setLevel(logging.INFO) + + +def get_aws_ip_ranges() -> Dict: + CACHE_DIR.mkdir(exist_ok=True) + + headers = {} + try: + file_time = datetime.fromtimestamp( + CACHE_FILE.stat().st_mtime, + tz=tz.UTC).strftime('%a, %d %b %Y %H:%M:%S GMT') + logger.debug(f'cached file modification time: {file_time}') + headers = {'If-Modified-Since': file_time} + except FileNotFoundError as e: + logger.debug(f'Not found: {CACHE_FILE}: {e}') + pass + + try: + response = requests.get( + url=AWS_IP_RANGES_URL, + timeout=5, + headers=headers + ) + + if response.status_code == 304: + try: + logger.debug(f'reading cached file {CACHE_FILE}') + with open(CACHE_FILE) as ip_ranges: + return json.load(ip_ranges) + except (OSError, IOError, json.JSONDecodeError) as e: + logger.debug(f'ERROR reading {CACHE_FILE}: {e}') + raise CachedFileException(str(e)) + elif response.status_code == 200: + try: + with open(CACHE_FILE, 'w') as f: + f.write(response.text) + except (OSError, IOError) as e: + logger.warning(e) + + return response.json() + else: + msg = f'Unexpected response from {AWS_IP_RANGES_URL}. Status code: ' \ + f'{response.status_code}. Content: {response.text}' + logger.debug(msg) + raise UnexpectedRequestException(msg) + except RequestException as e: + logger.debug(f'ERROR retrieving {AWS_IP_RANGES_URL}: {e}') + raise e + + +class CachedFileException(Exception): + def __init__(self, message: str): + message = f'Error reading cached ranges {CACHE_FILE}: {message}' + super(CachedFileException, self).__init__(message) + + +class UnexpectedRequestException(Exception): + def __init__(self, message: str): + super(UnexpectedRequestException, self).__init__(message) + + +class DigAWSPrettyPrinter: + def __init__(self, data: List[Dict], output_filter: List[str] = []): + self.data = data + self.output_filter = output_filter + + def plain_print(self) -> None: + for prefix in self.data: + try: + print(f'Prefix: {prefix["ip_prefix"]}') + except KeyError: + print(f'IPv6 Prefix: {prefix["ipv6_prefix"]}') + print(f'Region: {prefix["region"]}') + print(f'Service: {prefix["service"]}') + print(f'Network border group: {prefix["network_border_group"]}') + print('') + + def json_print(self) -> None: + data = [] + for prefix in self.data: + try: + prefix['ip_prefix'] + prefix_type = 'ip_prefix' + except KeyError: + prefix_type = 'ipv6_prefix' + data.append( + { + prefix_type: str(prefix[prefix_type]), + 'region': prefix['region'], + 'service': prefix['service'], + 'network_border_group': prefix['network_border_group'] + } + ) + if data: + print(json.dumps(data, indent=2)) + + +class DigAWS(): + def __init__(self, ip_ranges: Dict, output: str = 'plain', output_filter: List[str] = []): + self.output = output + self.output_filter = output_filter + self.ip_prefixes = [ + { + 'ip_prefix': ipaddress.IPv4Network(prefix['ip_prefix']), + 'region': prefix['region'], + 'service': prefix['service'], + 'network_border_group': prefix['network_border_group'] + } + for prefix in ip_ranges['prefixes'] + ] + self.ipv6_prefixes = [ + { + 'ipv6_prefix': ipaddress.IPv6Network(prefix['ipv6_prefix']), + 'region': prefix['region'], + 'service': prefix['service'], + 'network_border_group': prefix['network_border_group'] + } + for prefix in ip_ranges['ipv6_prefixes'] + ] + + def lookup(self, address: str) -> DigAWSPrettyPrinter: + return DigAWSPrettyPrinter( + self._lookup_data(address), + self.output_filter + ) + + def _lookup_data(self, address: str) -> List[Dict]: + addr: Any = None + try: + addr = ipaddress.IPv4Address(address) + data = [prefix for prefix in self.ip_prefixes + if addr in prefix['ip_prefix']] + except ipaddress.AddressValueError: + try: + addr = ipaddress.IPv6Address(address) + data = [prefix for prefix in self.ipv6_prefixes + if addr in prefix['ipv6_prefix']] + except ipaddress.AddressValueError: + try: + addr = ipaddress.IPv4Network(address) + data = [prefix for prefix in self.ip_prefixes + if addr.subnet_of(prefix['ip_prefix'])] + except (ipaddress.AddressValueError, ValueError): + try: + addr = ipaddress.IPv6Network(address) + data = [prefix for prefix in self.ipv6_prefixes + if addr.subnet_of(prefix['ipv6_prefix'])] + except (ipaddress.AddressValueError, ValueError): + raise(ValueError(f'Wrong IP or CIDR format: {address}')) + + return data + + +def arguments_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + add_help=True, + description=__description__ + ) + parser.add_argument( + '--output', + metavar='', + choices=['plain', 'json'], + type=str, + required=False, + dest='output', + default='plain', + help='Formatting style for command output, by default %(default)s' + ) + parser.add_argument( + '--debug', + action='store_true', + required=False, + default=False, + dest='debug', + help='Enable debug' + ) + parser.add_argument( + '--version', + action='version', + version='%(prog)s {version}'.format(version=__version__) + ) + parser.add_argument( + 'addresses', + nargs='+', + metavar='', + type=str, + help='CIDR or IP (v4 or v6) to look up' + ) + + return parser + + +def main(): + parser = arguments_parser() + args = parser.parse_args() + if args.debug: + logger.setLevel(logging.DEBUG) + + try: + ip_ranges = get_aws_ip_ranges() + dig = DigAWS(ip_ranges) + + responses = [] + for address in args.addresses: + responses.append(dig.lookup(address)) + + if args.output == 'plain': + for response in responses: + response.plain_print() + else: + if len(responses) == 1: + responses[0].json_print() + else: + joined = [] + for response in responses: + joined += response.data + + DigAWSPrettyPrinter(joined).json_print() + except ( + RequestException, + ipaddress.AddressValueError, + ValueError, + CachedFileException, + UnexpectedRequestException) as e: + print(f'ERROR: {e}') + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..2550f86 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,32 @@ +import nox + +nox.options.sessions = ['lint', 'typing', 'tests'] +locations = ['noxfile.py', 'setup.py', 'digaws/', 'tests/'] + +lint_common_args = ['--max-line-length', '120'] +mypy_args = ['--ignore-missing-imports'] +pytest_args = ['--cov=digaws', '--cov-report=', 'tests/'] +coverage_args = ['report', '--show-missing', '--fail-under=80'] + + +@nox.session() +def lint(session): + args = session.posargs or locations + session.install('pycodestyle', 'flake8', 'flake8-import-order') + session.run('pycodestyle', *(lint_common_args + args)) + session.run('flake8', *(lint_common_args + args)) + + +@nox.session() +def typing(session): + args = session.posargs or locations + session.install('mypy') + session.run('mypy', *(mypy_args + args)) + + +@nox.session() +def tests(session): + args = session.posargs + session.install('-r', 'requirements_test.txt') + session.run('pytest', *(pytest_args + args)) + session.run('coverage', *coverage_args) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..acff00d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-dateutil~=2.8 +requests~=2.25 diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..138a8b8 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,6 @@ +-r requirements.txt +coverage +pytest +pytest-cov +pytest-mock +pyfakefs diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..88598e4 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +from digaws import __description__, __version__ + +from setuptools import setup + + +def get_long_description() -> str: + with open('README.md', 'r', encoding='utf-8') as fh: + return fh.read() + + +setup( + name='digaws', + version=__version__, + description=__description__, + long_description=get_long_description(), + long_description_content_type='text/markdown', + url='http://github.com/dcarrillo/digaws', + author='Daniel Carrillo', + author_email='daniel.carrillo@gmail.com', + license='MIT', + packages=['digaws'], + zip_safe=False, + classifiers=[ + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + ], + python_requires='>=3.6', + entry_points={ + 'console_scripts': ['digaws=digaws.digaws:main'] + }, + install_requires=[ + 'python-dateutil~=2.8', + 'requests~=2.25', + ] +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a31b609 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,134 @@ +import ipaddress + +AWS_IP_RANGES = ''' +{ + "syncToken": "1608245058", + "createDate": "2020-12-17-22-44-18", + "prefixes": [ + { + "ip_prefix": "52.93.178.234/32", + "region": "us-west-1", + "service": "AMAZON", + "network_border_group": "us-west-1" + }, + { + "ip_prefix": "52.94.76.0/22", + "region": "us-west-2", + "service": "AMAZON", + "network_border_group": "us-west-2" + } + ], + "ipv6_prefixes": [ + { + "ipv6_prefix": "2600:1f00:c000::/40", + "region": "us-west-1", + "service": "AMAZON", + "network_border_group": "us-west-1" + }, + { + "ipv6_prefix": "2600:1f01:4874::/47", + "region": "us-west-2", + "service": "AMAZON", + "network_border_group": "us-west-2" + }, + { + "ipv6_prefix": "2600:1f14:fff:f800::/53", + "region": "us-west-2", + "service": "ROUTE53_HEALTHCHECKS", + "network_border_group": "us-west-2" + }, + { + "ipv6_prefix": "2600:1f14::/35", + "region": "us-west-2", + "service": "EC2", + "network_border_group": "us-west-2" + } + ] +} +''' +AWS_IPV4_RANGES_OBJ = [ + { + 'ip_prefix': ipaddress.IPv4Network('52.93.178.234/32'), + 'region': 'us-west-1', + 'service': 'AMAZON', + 'network_border_group': 'us-west-1' + }, + { + 'ip_prefix': ipaddress.IPv4Network('52.94.76.0/22'), + 'region': 'us-west-2', + 'service': 'AMAZON', + 'network_border_group': 'us-west-2' + } +] +AWS_IPV6_RANGES_OBJ = [ + { + 'ipv6_prefix': ipaddress.IPv6Network('2600:1f00:c000::/40'), + 'region': 'us-west-1', + 'service': 'AMAZON', + 'network_border_group': 'us-west-1' + }, + { + 'ipv6_prefix': ipaddress.IPv6Network('2600:1f01:4874::/47'), + 'region': 'us-west-2', + 'service': 'AMAZON', + 'network_border_group': 'us-west-2' + }, + { + 'ipv6_prefix': ipaddress.IPv6Network('2600:1f14:fff:f800::/53'), + 'region': 'us-west-2', + 'service': 'ROUTE53_HEALTHCHECKS', + 'network_border_group': 'us-west-2' + }, + { + 'ipv6_prefix': ipaddress.IPv6Network('2600:1f14::/35'), + 'region': 'us-west-2', + 'service': 'EC2', + 'network_border_group': 'us-west-2' + } +] +LAST_MODIFIED_TIME = 'Thu, 17 Dec 2020 23:22:33 GMT' + +RESPONSE_PLAIN_PRINT = '''Prefix: 52.94.76.0/22 +Region: us-west-2 +Service: AMAZON +Network border group: us-west-2 + +''' + +RESPONSE_JSON_PRINT = '''[ + { + "ipv6_prefix": "2600:1f14:fff:f800::/53", + "region": "us-west-2", + "service": "ROUTE53_HEALTHCHECKS", + "network_border_group": "us-west-2" + }, + { + "ipv6_prefix": "2600:1f14::/35", + "region": "us-west-2", + "service": "EC2", + "network_border_group": "us-west-2" + } +] +''' + +RESPONSE_JSON_JOINED_PRINT = '''[ + { + "ip_prefix": "52.94.76.0/22", + "region": "us-west-2", + "service": "AMAZON", + "network_border_group": "us-west-2" + }, + { + "ipv6_prefix": "2600:1f14:fff:f800::/53", + "region": "us-west-2", + "service": "ROUTE53_HEALTHCHECKS", + "network_border_group": "us-west-2" + }, + { + "ipv6_prefix": "2600:1f14::/35", + "region": "us-west-2", + "service": "EC2", + "network_border_group": "us-west-2" + } +] +''' diff --git a/tests/test_dig_aws.py b/tests/test_dig_aws.py new file mode 100644 index 0000000..9eb82a1 --- /dev/null +++ b/tests/test_dig_aws.py @@ -0,0 +1,77 @@ +import json +import sys + +import digaws.digaws as digaws +from digaws import __description__, __version__ + +import pytest + +import tests + + +@pytest.fixture +def test_dig(): + return digaws.DigAWS(json.loads(tests.AWS_IP_RANGES)) + + +def test_cli(capsys): + sys.argv = ['digaws', '-h'] + try: + digaws.main() + except SystemExit as e: + out, _ = capsys.readouterr() + assert __description__ in out + assert e.code == 0 + + +def test_cli_version(capsys, mocker): + sys.argv = ['digaws', '--version'] + try: + digaws.main() + except SystemExit as e: + out, _ = capsys.readouterr() + assert out == f'digaws {__version__}\n' + assert e.code == 0 + + +def test_cli_invocation(capsys, mocker): + sys.argv = ['digaws', '52.94.76.0/22', '2600:1f14:fff:f810:a1c1:f507:a2d1:2dd8', + '--output', 'json'] + mocker.patch('digaws.digaws.get_aws_ip_ranges', return_value=json.loads(tests.AWS_IP_RANGES)) + digaws.main() + out, _ = capsys.readouterr() + + assert out == tests.RESPONSE_JSON_JOINED_PRINT + + +def test_dig_aws_construct(test_dig): + assert test_dig.ip_prefixes == tests.AWS_IPV4_RANGES_OBJ + assert test_dig.ipv6_prefixes == tests.AWS_IPV6_RANGES_OBJ + + +def test_lookup(test_dig): + assert str(test_dig._lookup_data('52.94.76.1')[0]['ip_prefix']) == '52.94.76.0/22' + assert str(test_dig._lookup_data('52.94.76.0/24')[0]['ip_prefix']) == '52.94.76.0/22' + + input = '2600:1f14:fff:f810:a1c1:f507:a2d1:2dd8' + assert str(test_dig._lookup_data(input)[0]['ipv6_prefix']) == '2600:1f14:fff:f800::/53' + assert str(test_dig._lookup_data(input)[1]['ipv6_prefix']) == '2600:1f14::/35' + assert str(test_dig._lookup_data('2600:1f14::/36')[0]['ipv6_prefix']) == '2600:1f14::/35' + + with pytest.raises(ValueError) as e: + test_dig.lookup('what are you talking about') + assert e.startswith('Wrong IP or CIDR format') + + +def test_response_plain_print(test_dig, capsys): + test_dig.lookup('52.94.76.0/22').plain_print() + out, _ = capsys.readouterr() + + assert out == tests.RESPONSE_PLAIN_PRINT + + +def test_response_json_print(test_dig, capsys): + test_dig.lookup('2600:1f14:fff:f810:a1c1:f507:a2d1:2dd8').json_print() + out, _ = capsys.readouterr() + + assert out == tests.RESPONSE_JSON_PRINT diff --git a/tests/test_get_aws_ip_ranges_json.py b/tests/test_get_aws_ip_ranges_json.py new file mode 100644 index 0000000..7c5d3d1 --- /dev/null +++ b/tests/test_get_aws_ip_ranges_json.py @@ -0,0 +1,87 @@ +import json +import os + +import digaws.digaws as digaws + +import pytest + +import requests + +import tests + + +class MockGetResponse: + text = tests.AWS_IP_RANGES + status_code = 200 + + @staticmethod + def json(): + return json.loads(tests.AWS_IP_RANGES) + + +def mock_get(*args, **kwargs): + return MockGetResponse() + + +@pytest.fixture +def create_cache_dir(fs): + digaws.CACHE_DIR.mkdir(parents=True) + + +@pytest.mark.parametrize('fs', [[None, [digaws]]], indirect=True) +def test_get_aws_ip_ranges_cached_valid_file(mocker, fs, create_cache_dir) -> None: + with open(digaws.CACHE_FILE, 'w') as out: + out.write(tests.AWS_IP_RANGES) + + response = requests.Response + response.status_code = 304 + mocker.patch('requests.get', return_value=response) + + result = digaws.get_aws_ip_ranges() + + assert result['syncToken'] == '1608245058' + + +@pytest.mark.parametrize('fs', [[None, [digaws]]], indirect=True) +def test_get_aws_ip_ranges_cached_invalid_file(mocker, fs, create_cache_dir) -> None: + with open(digaws.CACHE_FILE, 'w'): + pass + + response = requests.Response + response.status_code = 304 + mocker.patch('requests.get', return_value=response) + + with pytest.raises(digaws.CachedFileException): + digaws.get_aws_ip_ranges() + + +@pytest.mark.parametrize('fs', [[None, [digaws]]], indirect=True) +def test_get_aws_ip_ranges_cached_deprecated_file(monkeypatch, fs, create_cache_dir) -> None: + with open(digaws.CACHE_FILE, 'w'): + pass + digaws.CACHE_FILE.touch() + os.utime(digaws.CACHE_FILE, times=(0, 0)) + + monkeypatch.setattr(requests, 'get', mock_get) + result = digaws.get_aws_ip_ranges() + + assert result['syncToken'] == '1608245058' + + +@pytest.mark.parametrize('fs', [[None, [digaws]]], indirect=True) +def test_get_aws_ip_ranges_no_file(monkeypatch, fs, create_cache_dir) -> None: + monkeypatch.setattr(requests, 'get', mock_get) + result = digaws.get_aws_ip_ranges() + + assert result['syncToken'] == '1608245058' + + +@pytest.mark.parametrize('fs', [[None, [digaws]]], indirect=True) +def test_get_aws_ip_ranges_invalid_status(mocker, fs, create_cache_dir) -> None: + response = requests.Response + response.status_code = 301 + mocker.patch('requests.get', return_value=response) + + with pytest.raises(digaws.UnexpectedRequestException) as e: + digaws.get_aws_ip_ranges() + assert e.startswith('Unexpected response from')