├── tests ├── __init__.py ├── test_wifi.py ├── test_ntp.py ├── test_http.py └── test_ping.py ├── docs ├── requirements.txt ├── changelog.rst ├── contributing.rst ├── .readthedocs.yaml ├── index.rst ├── installation.rst ├── Makefile ├── make.bat ├── use.rst └── conf.py ├── setup.cfg ├── MANIFEST.in ├── tox.ini ├── ripe ├── atlas │ ├── sagan │ │ ├── helpers │ │ │ ├── __init__.py │ │ │ └── compatibility.py │ │ ├── version.py │ │ ├── __init__.py │ │ ├── wifi.py │ │ ├── http.py │ │ ├── ping.py │ │ ├── ntp.py │ │ ├── traceroute.py │ │ ├── ssl.py │ │ ├── base.py │ │ └── dns.py │ └── __init__.py └── __init__.py ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── python-package.yml ├── setup.py ├── CONTRIBUTING.rst ├── CHANGES.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-rtd-theme -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include LICENSE 3 | include README.rst 4 | include MANIFEST.in 5 | recursive-include ripe *.py 6 | recursive-include tests *.py 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # commands=python setup.py test [] 2 | # deps=nose 3 | 4 | [testenv] 5 | deps = 6 | flake8 7 | pytest 8 | commands = 9 | # flake8 --max-line-length=88 setup.py ripe/atlas/sagan/ tests/ 10 | pytest {posargs} 11 | -------------------------------------------------------------------------------- /docs/.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.12" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/version.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | __version__ = "2.0.0" 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .eggs 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyCharm 57 | .idea 58 | 59 | -------------------------------------------------------------------------------- /ripe/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | try: 17 | __import__('pkg_resources').declare_namespace(__name__) 18 | except ImportError: 19 | from pkgutil import extend_path 20 | __path__ = extend_path(__path__, __name__) 21 | -------------------------------------------------------------------------------- /ripe/atlas/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | try: 17 | __import__('pkg_resources').declare_namespace(__name__) 18 | except ImportError: 19 | from pkgutil import extend_path 20 | __path__ = extend_path(__path__, __name__) 21 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/helpers/compatibility.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | """ 17 | We put stuff here to help cope with differences between Python versions. 18 | """ 19 | 20 | try: 21 | string = basestring # Python2 22 | except NameError: 23 | string = str # Python3 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ '*' ] 9 | pull_request: 10 | branches: [ $default-branch ] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: ["3.10", "3.11", "3.12", "3.13"] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install tox 30 | - name: tox 31 | run: tox 32 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. RIPE Atlas Sagan documentation master file, created by 2 | sphinx-quickstart on Tue Apr 29 13:41:57 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to RIPE Atlas Sagan's documentation! 7 | ******************************************** 8 | 9 | A parsing library for RIPE Atlas measurement results 10 | 11 | .. _index-why-this-exists: 12 | 13 | Why This Exists 14 | =============== 15 | 16 | RIPE Atlas generates a **lot** of data, and the format of that data changes over 17 | time. Often you want to do something simple like fetch the median RTT for each 18 | measurement result between date `X` and date `Y`. Unfortunately, there are are 19 | dozens of edge cases to account for while parsing the JSON, like the format of 20 | errors and firmware upgrades that changed the format entirely. 21 | 22 | To make this easier for our users (and for ourselves), we wrote an easy to use 23 | parser that's smart enough to figure out the best course of action for each 24 | result, and return to you a useful, native Python object. 25 | 26 | Contents: 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | 31 | installation 32 | use 33 | types 34 | contributing 35 | changelog 36 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from __future__ import absolute_import 17 | 18 | from .base import Result, ResultError, ResultParseError 19 | from .dns import DnsResult 20 | from .http import HttpResult 21 | from .ping import PingResult 22 | from .ssl import SslResult 23 | from .traceroute import TracerouteResult 24 | from .ntp import NtpResult 25 | 26 | from .version import __version__ 27 | 28 | __all__ = ( 29 | "Result", 30 | "PingResult", 31 | "TracerouteResult", 32 | "DnsResult", 33 | "SslResult", 34 | "HttpResult", 35 | "NtpResult", 36 | ) 37 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/wifi.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from .base import Result, ParsingDict 17 | 18 | 19 | class WPASupplicant(ParsingDict): 20 | 21 | def __init__(self, data, **kwargs): 22 | 23 | ParsingDict.__init__(self, **kwargs) 24 | 25 | self.address = data.get("address") 26 | self.bssid = data.get("bssid") 27 | self.connect_time = data.get("connect-time") 28 | self.group_cipher = data.get("group_cipher") 29 | self.wpa_supplicant_id = data.get("id") 30 | self.ip_address = data.get("ip_address") 31 | self.key_management = data.get("key_mgmt") 32 | self.mode = data.get("mode") 33 | self.pairwise_cipher = data.get("pairwise_cipher") 34 | self.ssid = data.get("ssid") 35 | self.wpa_state = data.get("wpa_state") 36 | 37 | 38 | class WiFiResult(Result): 39 | """ 40 | WiFi measurement result class 41 | """ 42 | 43 | def __init__(self, data, **kwargs): 44 | 45 | Result.__init__(self, data, **kwargs) 46 | 47 | self.wpa_supplicant = WPASupplicant( 48 | data["wpa_supplicant"], **kwargs 49 | ) 50 | 51 | __all__ = ( 52 | "WiFiResult", 53 | ) 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from os.path import abspath, dirname, join 3 | from setuptools import setup 4 | 5 | __version__ = None 6 | exec(open("ripe/atlas/sagan/version.py").read()) 7 | 8 | name = "ripe.atlas.sagan" 9 | install_requires = [ 10 | "python-dateutil", 11 | "pytz", 12 | "cryptography", 13 | ] 14 | 15 | # Allow setup.py to be run from any path 16 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 17 | 18 | # Get proper long description for package 19 | current_dir = dirname(abspath(__file__)) 20 | description = open(join(current_dir, "README.rst")).read() 21 | changes = open(join(current_dir, "CHANGES.rst")).read() 22 | long_description = "\n\n".join([description, changes]) 23 | 24 | setup( 25 | name="ripe.atlas.sagan", 26 | version=__version__, 27 | packages=["ripe", "ripe.atlas", "ripe.atlas.sagan"], 28 | namespace_packages=["ripe", "ripe.atlas"], 29 | include_package_data=True, 30 | license="GPLv3", 31 | description="A parser for RIPE Atlas measurement results", 32 | long_description=long_description, 33 | url="https://github.com/RIPE-NCC/ripe.atlas.sagan", 34 | download_url="https://github.com/RIPE-NCC/ripe.atlas.sagan", 35 | author="The RIPE Atlas Team", 36 | author_email="atlas@ripe.net", 37 | maintainer="The RIPE Atlas Team", 38 | maintainer_email="atlas@ripe.net", 39 | install_requires=install_requires, 40 | extras_require={ 41 | "fast": ["ujson"], 42 | "doc": ["sphinx"] 43 | }, 44 | classifiers=[ 45 | "Operating System :: POSIX", 46 | "Operating System :: Unix", 47 | "Programming Language :: Python", 48 | "Programming Language :: Python :: 3.10", 49 | "Programming Language :: Python :: 3.11", 50 | "Programming Language :: Python :: 3.12", 51 | "Programming Language :: Python :: 3.13", 52 | "Topic :: Internet :: WWW/HTTP", 53 | ], 54 | ) 55 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _requirements-and-installation: 2 | 3 | Requirements & Installation 4 | *************************** 5 | 6 | .. _installation-requirements: 7 | 8 | Requirements 9 | ============ 10 | 11 | As you might have guessed, with all of the magic going on under the hood, there 12 | are a few dependencies: 13 | 14 | * `cryptography`_ 15 | * `python-dateutil`_ 16 | * `pytz`_ 17 | 18 | Additionally, we recommend that you also install `ujson`_ as it will speed up 19 | the JSON-decoding step considerably, and `sphinx`_ if you intend to build the 20 | documentation files for offline use. 21 | 22 | .. _cryptography: https://pypi.python.org/pypi/cryptography 23 | .. _python-dateutil: https://pypi.python.org/pypi/python-dateutil/ 24 | .. _pytz: https://pypi.python.org/pypi/pytz/ 25 | .. _ujson: https://pypi.python.org/pypi/ujson/ 26 | .. _sphinx: https://pypi.python.org/pypi/Sphinx/ 27 | 28 | 29 | .. _installation: 30 | 31 | Installation 32 | ============ 33 | 34 | Installation should be easy, though it may take a while to install all of the 35 | aforementioned requirements. Using pip is the recommended method. 36 | 37 | 38 | .. _installation-from-pip: 39 | 40 | Using pip 41 | --------- 42 | 43 | The quickest and easiest way to install Sagan is to use ``pip``:: 44 | 45 | $ pip install ripe.atlas.sagan 46 | 47 | 48 | .. _installation-from-github: 49 | 50 | From GitHub 51 | ----------- 52 | 53 | If you're feeling a little more daring and want to use whatever is on GitHub, 54 | you can have pip install right from there:: 55 | 56 | $ pip install git+https://github.com/RIPE-NCC/ripe.atlas.sagan.git 57 | 58 | 59 | .. _installation-from-tarball: 60 | 61 | From a Tarball 62 | -------------- 63 | 64 | If for some reason you want to just download the source and install it manually, 65 | you can always do that too. Simply un-tar the file and run the following in the 66 | same directory as ``setup.py``.:: 67 | 68 | $ python setup.py install 69 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs whenever a new tag is pushed. 2 | # It will install Python dependencies, run tests and lint with a variety of Python versions 3 | # If tests pass, it will build the package and publish it on pypi.org 4 | 5 | name: Python package 6 | 7 | on: 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: ["3.10", "3.11", "3.12", "3.13"] 19 | 20 | steps: 21 | - uses: actions/checkout@v5 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install tox 30 | - name: tox 31 | run: tox 32 | 33 | build: 34 | name: Build package for PyPI 35 | runs-on: ubuntu-latest 36 | needs: test 37 | steps: 38 | - uses: actions/checkout@v5 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v5 41 | with: 42 | python-version: '3.11' 43 | - name: Build release 44 | run: | 45 | python -m pip install --upgrade build 46 | python -m build 47 | - name: Upload dist 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: release-dists 51 | path: dist/ 52 | 53 | publish: 54 | name: Publish package to PyPI 55 | runs-on: ubuntu-latest 56 | needs: build 57 | steps: 58 | - name: Retrieve dist 59 | uses: actions/download-artifact@v5 60 | with: 61 | name: release-dists 62 | path: dist/ 63 | - name: Push to pypi 64 | # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 65 | uses: pypa/gh-action-pypi-publish@release/v1.13 66 | with: 67 | user: __token__ 68 | password: ${{ secrets.PYPI_API_TOKEN }} 69 | verbose: true 70 | 71 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | How To Contribute 2 | ================= 3 | 4 | We would love to have contributions from everyone and no contribution is too 5 | small. Please submit as many fixes for typos and grammar bloopers as you can! 6 | 7 | To make participation in this project as pleasant as possible for everyone, 8 | we adhere to the `Code of Conduct`_ by the Python Software Foundation. 9 | 10 | The following steps will help you get started: 11 | 12 | Fork, then clone the repo: 13 | 14 | .. code:: bash 15 | 16 | $ git clone git@github.com:your-username/ripe.atlas.sagan.git 17 | 18 | Make sure the tests pass beforehand: 19 | 20 | .. code:: bash 21 | 22 | $ tox 23 | 24 | or 25 | 26 | .. code:: bash 27 | 28 | $ nosetests tests/ 29 | 30 | Make your changes. Include tests for your change. Make the tests pass: 31 | 32 | .. code:: bash 33 | 34 | $ tox 35 | 36 | or 37 | 38 | .. code:: bash 39 | 40 | $ nosetests tests/ 41 | 42 | Push to your fork and `submit a pull request`_. 43 | 44 | Here are a few guidelines that will increase the chances of a quick merge of 45 | your pull request: 46 | 47 | - *Always* try to add tests and docs for your code. If a feature is tested and 48 | documented, it's easier for us to merge it. 49 | - Follow `PEP 8`_. 50 | - Write `good commit messages`_. 51 | - If you change something that is noteworthy, don't forget to add an entry to 52 | the `changes`_. 53 | 54 | .. note:: 55 | - If you think you have a great contribution but aren’t sure whether it 56 | adheres -- or even can adhere -- to the rules: **please submit a pull 57 | request anyway**! In the best case, we can transform it into something 58 | usable, in the worst case the pull request gets politely closed. There’s 59 | absolutely nothing to fear. 60 | - If you have a great idea but you don't know how or don't have the time to 61 | implement it, please consider opening an issue and someone will pick it up 62 | as soon as possible. 63 | 64 | Thank you for considering a contribution to this project! If you have any 65 | questions or concerns, feel free to reach out the RIPE Atlas team via the 66 | `mailing list`_, `GitHub Issue Queue`_, or `messenger pigeon`_ -- if you must. 67 | 68 | .. _submit a pull request: https://github.com/RIPE-NCC/ripe.atlas.sagan/compare/ 69 | .. _PEP 8: https://www.python.org/dev/peps/pep-0008/ 70 | .. _good commit messages: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 71 | .. _Code of Conduct: https://www.python.org/psf/codeofconduct/ 72 | .. _changes: https://github.com/RIPE-NCC/ripe.atlas.sagan/blob/master/CHANGES.rst 73 | .. _mailing list: https://www.ripe.net/mailman/listinfo/ripe-atlas 74 | .. _GitHub Issue Queue: https://github.com/RIPE-NCC/ripe.atlas.sagan/issues 75 | .. _messenger pigeon: https://tools.ietf.org/html/rfc1149 76 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/http.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from .base import Result, ParsingDict 17 | 18 | 19 | class Response(ParsingDict): 20 | 21 | def __init__(self, data, **kwargs): 22 | 23 | ParsingDict.__init__(self, **kwargs) 24 | 25 | self.raw_data = data 26 | self.af = self.ensure("af", int) 27 | self.body_size = self.ensure("bsize", int) 28 | self.head_size = self.ensure("hsize", int) 29 | self.destination_address = self.ensure("dst_addr", str) 30 | self.source_address = self.ensure("src_addr", str) 31 | self.code = self.ensure("res", int) 32 | self.response_time = self.ensure("rt", float) 33 | self.version = self.ensure("ver", str) 34 | 35 | if not self.destination_address: 36 | self.destination_address = self.ensure( 37 | "addr", str, self.destination_address) 38 | 39 | if not self.source_address: 40 | self.source_address = self.ensure( 41 | "srcaddr", str, self.source_address) 42 | 43 | if not self.code: 44 | self._handle_malformation("No response code available") 45 | 46 | error = self.ensure("err", str) 47 | if error: 48 | self._handle_error(error) 49 | 50 | 51 | class HttpResult(Result): 52 | 53 | METHOD_GET = "GET" 54 | METHOD_POST = "POST" 55 | METHOD_PUT = "PUT" 56 | METHOD_DELETE = "DELETE" 57 | METHOD_HEAD = "HEAD" 58 | METHODS = { 59 | METHOD_GET: "GET", 60 | METHOD_POST: "POST", 61 | METHOD_PUT: "PUT", 62 | METHOD_DELETE: "DELETE", 63 | METHOD_HEAD: "HEAD" 64 | } 65 | 66 | def __init__(self, data, **kwargs): 67 | 68 | Result.__init__(self, data, **kwargs) 69 | 70 | self.uri = self.ensure("uri", str) 71 | self.method = None 72 | 73 | self.responses = [] 74 | 75 | if "result" not in self.raw_data: 76 | self._handle_malformation("No result value found") 77 | return 78 | 79 | if isinstance(self.raw_data["result"], list): 80 | 81 | # All modern results 82 | 83 | for response in self.raw_data["result"]: 84 | self.responses.append(Response(response, **kwargs)) 85 | 86 | if self.responses: 87 | method = self.raw_data["result"][0].get( 88 | "method", 89 | self.raw_data["result"][0].get("mode") # Firmware == 4300 90 | ) 91 | if method: 92 | method = method.replace("4", "").replace("6", "") 93 | if method in self.METHODS.keys(): 94 | self.method = self.METHODS[method] 95 | 96 | else: 97 | 98 | # Firmware <= 1 99 | 100 | response = self.raw_data["result"].split(" ") 101 | self.method = response[0].replace("4", "").replace("6", "") 102 | self.responses.append(Response({ 103 | "dst_addr": response[1], 104 | "rt": float(response[2]) * 1000, 105 | "res": int(response[3]), 106 | "hsize": int(response[4]), 107 | "bsize": int(response[5]), 108 | })) 109 | 110 | 111 | __all__ = ( 112 | "HttpResult" 113 | ) 114 | -------------------------------------------------------------------------------- /tests/test_wifi.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from ripe.atlas.sagan import Result 17 | from ripe.atlas.sagan.wifi import WiFiResult 18 | 19 | 20 | def test_wifi(): 21 | raw_data = { 22 | "bundle": 1463495978, 23 | "from": "2001:67c:2e8:ffe1:eade:27ff:fe69:e6f0", 24 | "fw": "4733", 25 | "group_id": 1021275, 26 | "msm_id": 1021275, 27 | "msm_name": "WiFi", 28 | "prb_id": 105, 29 | "timestamp": 1463495978, 30 | "type": "wifi", 31 | "wpa_supplicant": { 32 | "address": "ea:de:27:69:e6:f0", 33 | "bssid": "08:ea:44:3b:6d:14", 34 | "connect-time": "2", 35 | "group_cipher": "CCMP", 36 | "id": "0", 37 | "ip_address": "193.0.10.126", 38 | "key_mgmt": "WPA2-PSK", 39 | "mode": "station", 40 | "pairwise_cipher": "CCMP", 41 | "ssid": "guestnet", 42 | "wpa_state": "COMPLETED" 43 | } 44 | } 45 | 46 | result = Result.get(raw_data) 47 | assert(isinstance(result, WiFiResult)) 48 | assert(result.bundle == 1463495978) 49 | assert(result.origin == "2001:67c:2e8:ffe1:eade:27ff:fe69:e6f0") 50 | assert(result.firmware == 4733) 51 | assert(result.group_id == 1021275) 52 | assert(result.wpa_supplicant.address == "ea:de:27:69:e6:f0") 53 | assert(result.wpa_supplicant.bssid == "08:ea:44:3b:6d:14") 54 | assert(result.wpa_supplicant.connect_time == "2") 55 | assert(result.wpa_supplicant.group_cipher == "CCMP") 56 | assert(result.wpa_supplicant.wpa_supplicant_id == "0") 57 | assert(result.wpa_supplicant.ip_address == "193.0.10.126") 58 | assert(result.wpa_supplicant.key_management == "WPA2-PSK") 59 | assert(result.wpa_supplicant.mode == "station") 60 | assert(result.wpa_supplicant.pairwise_cipher == "CCMP") 61 | assert(result.wpa_supplicant.ssid == "guestnet") 62 | assert(result.wpa_supplicant.wpa_state == "COMPLETED") 63 | 64 | 65 | def test_wifi_error(): 66 | raw_data = { 67 | "bundle": 1463493022, 68 | "error": "wpa timeout", 69 | "from": "2001:67c:2e8:ffe1:eade:27ff:fe69:e6f0", 70 | "fw": "4733", 71 | "group_id": 1021275, 72 | "msm_id": 1021275, 73 | "msm_name": "WiFi", 74 | "prb_id": 105, 75 | "timestamp": 1463493023, 76 | "type": "wifi", 77 | "wpa_supplicant": {"connect-time": "11"} 78 | } 79 | 80 | result = Result.get(raw_data) 81 | assert(isinstance(result, WiFiResult)) 82 | assert(result.bundle == 1463493022) 83 | assert(result.origin == "2001:67c:2e8:ffe1:eade:27ff:fe69:e6f0") 84 | assert(result.firmware == 4733) 85 | assert(result.group_id == 1021275) 86 | assert(result.created_timestamp == 1463493023) 87 | assert(result.wpa_supplicant.address is None) 88 | assert(result.wpa_supplicant.bssid is None) 89 | assert(result.wpa_supplicant.connect_time == "11") 90 | assert(result.wpa_supplicant.group_cipher is None) 91 | assert(result.wpa_supplicant.wpa_supplicant_id is None) 92 | assert(result.wpa_supplicant.ip_address is None) 93 | assert(result.wpa_supplicant.key_management is None) 94 | assert(result.wpa_supplicant.mode is None) 95 | assert(result.wpa_supplicant.pairwise_cipher is None) 96 | assert(result.wpa_supplicant.ssid is None) 97 | assert(result.wpa_supplicant.wpa_state is None) 98 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/ping.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from .base import Result, ResultParseError, ParsingDict 17 | 18 | 19 | class Packet(ParsingDict): 20 | 21 | def __init__(self, data, default_ttl, default_source_address, **kwargs): 22 | 23 | ParsingDict.__init__(self, **kwargs) 24 | 25 | self.rtt = None 26 | self.dup = False 27 | self.ttl = None 28 | 29 | self.source_address = data.get( 30 | "src_addr", 31 | data.get( 32 | "srcaddr", default_source_address 33 | ) 34 | ) 35 | 36 | if "rtt" in data: 37 | try: 38 | self.rtt = round(float(data["rtt"]), 3) 39 | except (ValueError, TypeError): 40 | raise ResultParseError( 41 | 'RTT "{rtt}" does not appear to be a float'.format( 42 | rtt=data["rtt"] 43 | ) 44 | ) 45 | 46 | if self.rtt: 47 | self.ttl = default_ttl 48 | if "ttl" in data: 49 | try: 50 | self.ttl = int(data["ttl"]) 51 | except (ValueError, TypeError): 52 | raise ResultParseError( 53 | 'TTL "{ttl}" does not appear to be an integer'.format( 54 | ttl=data["ttl"] 55 | ) 56 | ) 57 | 58 | if "dup" in data: 59 | self.dup = True 60 | 61 | def __str__(self): 62 | return str(self.rtt) 63 | 64 | 65 | class PingResult(Result): 66 | """ 67 | Ping measurement result class 68 | """ 69 | 70 | def __init__(self, data, **kwargs): 71 | 72 | Result.__init__(self, data, **kwargs) 73 | 74 | self.af = self.ensure("af", int) 75 | self.duplicates = self.ensure("dup", int) 76 | self.rtt_average = self.ensure("avg", float) 77 | self.rtt_max = self.ensure("max", float) 78 | self.rtt_min = self.ensure("min", float) 79 | self.packets_sent = self.ensure("sent", int) 80 | self.packets_received = self.ensure("rcvd", int) 81 | self.packet_size = self.ensure("size", int) 82 | self.destination_name = self.ensure("dst_name", str) 83 | self.destination_address = self.ensure("dst_addr", str) 84 | self.step = self.ensure("step", int) 85 | self.rtt_median = None # Redefined in self._set_rtt_median() 86 | self.packets = [] 87 | 88 | if self.rtt_average is None or self.rtt_average < 0: 89 | self.rtt_average = self.rtt_min = self.rtt_max = None 90 | 91 | if 0 < self.firmware < 4460: 92 | self.af = self.ensure("pf", int) 93 | 94 | self.protocol = self.clean_protocol(self.ensure("proto", str)) 95 | 96 | if 0 < self.firmware < 4460: 97 | self.destination_address = self.ensure("addr", str) 98 | self.destination_name = self.ensure("name", str) 99 | self.packet_size = None 100 | elif 0 < self.firmware < 4570 and self.protocol == self.PROTOCOL_ICMP: 101 | self.packet_size -= 8 102 | 103 | if self.af is None and self.destination_address: 104 | self.af = 4 105 | if ":" in self.destination_address: 106 | self.af = 6 107 | 108 | if self.rtt_average: 109 | self.rtt_average = round(self.rtt_average, 3) 110 | 111 | self._parse_packets(**kwargs) 112 | self._set_rtt_median() 113 | 114 | def _parse_packets(self, **kwargs): 115 | 116 | source_address = self.raw_data.get( 117 | "src_addr", self.raw_data.get("srcaddr") 118 | ) 119 | for packet in self.ensure("result", list, []): 120 | self.packets.append( 121 | Packet( 122 | packet, 123 | self.ensure("ttl", int), 124 | source_address, 125 | **kwargs 126 | ) 127 | ) 128 | 129 | def _set_rtt_median(self): 130 | packets = sorted([ 131 | p.rtt for p in self.packets if p.rtt is not None and p.dup is False 132 | ]) 133 | self.rtt_median = self.calculate_median(packets) 134 | 135 | __all__ = ( 136 | "PingResult", 137 | ) 138 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/ntp.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from datetime import datetime 17 | from dateutil.relativedelta import relativedelta 18 | from pytz import UTC 19 | 20 | from .base import Result, ResultParseError, ParsingDict 21 | 22 | 23 | class Packet(ParsingDict): 24 | """ 25 | Model for data structure of each packet for a NTP result. 26 | """ 27 | 28 | NTP_EPOCH = datetime(1900, 1, 1, tzinfo=UTC) 29 | 30 | def __init__(self, data, **kwargs): 31 | 32 | ParsingDict.__init__(self, **kwargs) 33 | 34 | self.raw_data = data 35 | self.rtt = None 36 | self.offset = None 37 | 38 | if "rtt" not in data: 39 | return 40 | 41 | try: 42 | self.rtt = round(float(data["rtt"]), 3) 43 | except (ValueError, TypeError): 44 | raise ResultParseError( 45 | 'RTT "{rtt}" does not appear to be a float'.format( 46 | rtt=data["rtt"]) 47 | ) 48 | 49 | self.offset = self.ensure("offset", float) 50 | self.final_timestamp = self.ensure("final-ts", float) 51 | self.origin_timestamp = self.ensure("origin-ts", float) 52 | self.received_timestamp = self.ensure("receive-ts", float) 53 | self.transmitted_timestamp = self.ensure("transmit-ts", float) 54 | 55 | # Caching 56 | 57 | self._final_time = None 58 | self._origin_time = None 59 | self._received_time = None 60 | self._transmitted_time = None 61 | 62 | def __str__(self): 63 | return "{rtt}|{offset}".format(rtt=self.rtt, offset=self.offset) 64 | 65 | @property 66 | def final_time(self): 67 | if not self._final_time and self.final_timestamp: 68 | self._final_time = self.NTP_EPOCH + relativedelta( 69 | seconds=self.final_timestamp) 70 | return self._final_time 71 | 72 | @property 73 | def origin_time(self): 74 | if not self._origin_time and self.origin_timestamp: 75 | self._origin_time = self.NTP_EPOCH + relativedelta( 76 | seconds=self.origin_timestamp) 77 | return self._origin_time 78 | 79 | @property 80 | def received_time(self): 81 | if not self._received_time and self.received_timestamp: 82 | self._received_time = self.NTP_EPOCH + relativedelta( 83 | seconds=self.received_timestamp) 84 | return self._received_time 85 | 86 | @property 87 | def transmitted_time(self): 88 | if not self._transmitted_time and self.transmitted_timestamp: 89 | self._transmitted_time = self.NTP_EPOCH + relativedelta( 90 | seconds=self.transmitted_timestamp) 91 | return self._transmitted_time 92 | 93 | 94 | class NtpResult(Result): 95 | """ 96 | Subclass to cover ntp type measurement results. 97 | """ 98 | 99 | def __init__(self, data, **kwargs): 100 | 101 | Result.__init__(self, data, **kwargs) 102 | 103 | self.rtt_median = None 104 | self.rtt_min = None 105 | self.rtt_max = None 106 | self.offset_median = None 107 | self.offset_min = None 108 | self.offset_max = None 109 | 110 | self.af = self.ensure("af", int) 111 | self.protocol = self.ensure("proto", str) 112 | self.destination_address = self.ensure("dst_addr", str) 113 | self.destination_name = self.ensure("dst_name", str) 114 | self.source_address = self.ensure("src_addr", str) 115 | self.end_time = self.ensure("endtime", "datetime") 116 | self.leap_second_indicator = self.ensure("li", str) 117 | self.mode = self.ensure("mode", str) 118 | self.poll = self.ensure("poll", int) 119 | self.precision = self.ensure("precision", float) 120 | self.reference_id = self.ensure("ref-id", str) 121 | self.reference_time = self.ensure("ref-ts", float) 122 | self.root_delay = self.ensure("root-delay", int) 123 | self.root_dispersion = self.ensure("root-dispersion", float) 124 | self.stratum = self.ensure("stratum", int) 125 | self.version = self.ensure("version", int) 126 | 127 | self.packets = [] 128 | 129 | if "result" not in self.raw_data: 130 | self._handle_malformation("No result value found") 131 | return 132 | 133 | for response in self.raw_data["result"]: 134 | self.packets.append(Packet(response, **kwargs)) 135 | 136 | self._set_medians_and_extremes() 137 | 138 | def _set_medians_and_extremes(self): 139 | """ 140 | Sets median values for rtt and the offset of result packets. 141 | """ 142 | 143 | rtts = sorted([p.rtt for p in self.packets if p.rtt is not None]) 144 | if rtts: 145 | self.rtt_min = rtts[0] 146 | self.rtt_max = rtts[-1] 147 | self.rtt_median = self.calculate_median(rtts) 148 | 149 | offsets = sorted( 150 | [p.offset for p in self.packets if p.offset is not None] 151 | ) 152 | if offsets: 153 | self.offset_min = offsets[0] 154 | self.offset_max = offsets[-1] 155 | self.offset_median = self.calculate_median(offsets) 156 | 157 | 158 | __all__ = ( 159 | "NtpResult" 160 | ) 161 | -------------------------------------------------------------------------------- /tests/test_ntp.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from ripe.atlas.sagan import Result 17 | from ripe.atlas.sagan.ntp import NtpResult 18 | 19 | def test_ntp_valid(): 20 | result = ( 21 | '{"af":4,"dst_addr":"193.0.0.229","dst_name":"atlas","from":"193.0.0.78","fw":4670,' 22 | '"group_id":1020237,"li":"no","lts":-1,"mode":"server","msm_id":1020237,"msm_name":"Ntp",' 23 | '"poll":1,"prb_id":71,"precision":0.0000019074,"proto":"UDP","ref-id":"GPS",' 24 | '"ref-ts":3627199357.7446351051,"result":[' 25 | '{"final-ts":3627199379.8182010651,"offset":-8.363271,"origin-ts":3627199379.7962741852,' 26 | '"receive-ts":3627199388.1704945564,"rtt":0.021899,"transmit-ts":3627199388.170522213},' 27 | '{"final-ts":3627199379.831638813,"offset":-8.36871,"origin-ts":3627199379.8214530945,' 28 | '"receive-ts":3627199388.1952428818,"rtt":0.01016,"transmit-ts":3627199388.195268631},' 29 | '{"final-ts":3627199379.8474769592,"offset":-8.372775,"origin-ts":3627199379.8454480171,' 30 | '"receive-ts":3627199388.2192249298,"rtt":0.002004,"transmit-ts":3627199388.2192502022}' 31 | '],' 32 | '"root-delay":0,"root-dispersion":0.00140381,"src_addr":"10.0.2.12","stratum":1,' 33 | '"timestamp":1418210579,"type":"ntp","version":4}' 34 | ) 35 | result = Result.get(result) 36 | assert(isinstance(result, NtpResult)) 37 | assert(result.af == 4) 38 | assert(result.firmware == 4670) 39 | assert(result.destination_address == "193.0.0.229") 40 | assert(result.destination_name == "atlas") 41 | assert(result.source_address == "10.0.2.12") 42 | assert(result.origin == "193.0.0.78") 43 | assert(result.leap_second_indicator == "no") 44 | assert(result.mode == "server") 45 | assert(result.poll == 1) 46 | assert(result.precision == 1.9074e-06) 47 | assert(result.protocol == "UDP") 48 | assert(result.reference_id == "GPS") 49 | assert(result.reference_time == 3627199357.7446351051) 50 | assert(result.root_delay == 0) 51 | assert(round(result.root_dispersion, 8) == 0.00140381) 52 | assert(result.stratum == 1) 53 | assert(result.version == 4) 54 | assert(result.packets[0].final_timestamp == 3627199379.8182010651) 55 | assert(result.packets[0].final_time.isoformat() == "2014-12-10T11:22:59.818201+00:00") 56 | assert(result.packets[0].offset == -8.363271) 57 | assert(result.packets[0].rtt == 0.022) 58 | assert(result.packets[0].origin_timestamp == 3627199379.7962741852) 59 | assert(result.packets[0].origin_time.isoformat() == "2014-12-10T11:22:59.796274+00:00") 60 | assert(result.packets[0].received_timestamp == 3627199388.1704945564) 61 | assert(result.packets[0].received_time.isoformat() == "2014-12-10T11:23:08.170495+00:00") 62 | assert(result.packets[0].transmitted_timestamp == 3627199388.170522213) 63 | assert(result.packets[0].transmitted_time.isoformat() == "2014-12-10T11:23:08.170522+00:00") 64 | assert(result.rtt_min == 0.002) 65 | assert(result.rtt_max == 0.022) 66 | assert(result.rtt_median == 0.01) 67 | assert(result.offset_min == -8.372775) 68 | assert(result.offset_max == -8.363271) 69 | assert(result.offset_median == -8.36871) 70 | 71 | 72 | def test_ntp_timeout(): 73 | result = ( 74 | '{ "msm_id":"1020235", "fw":4661, "lts":76, "timestamp":1418196642, "dst_name":"atlas", ' 75 | '"prb_id":71, "dst_addr":"193.0.6.139", "src_addr":"193.0.10.127", "proto":"UDP", "af": 4,' 76 | '"from": "193.0.0.78", "type": "ntp", "result": ' 77 | '[ { "x":"*" }, { "x":"*" }, { "x":"*" } ] ' 78 | '}' 79 | ) 80 | result = Result.get(result) 81 | assert(isinstance(result, NtpResult)) 82 | assert(result.af == 4) 83 | assert(result.firmware == 4661) 84 | assert(result.destination_address == "193.0.6.139") 85 | assert(result.destination_name == "atlas") 86 | assert(result.source_address == "193.0.10.127") 87 | assert(result.origin == "193.0.0.78") 88 | assert(result.leap_second_indicator is None) 89 | assert(result.stratum is None) 90 | assert(result.rtt_median is None) 91 | assert(result.offset_median is None) 92 | assert(getattr(result.packets[0], "final_timestamp", None) is None) 93 | assert(getattr(result.packets[0], "final_times", None) is None) 94 | assert(getattr(result.packets[0], "origin_timestamp", None) is None) 95 | assert(getattr(result.packets[0], "origin_time", None) is None) 96 | assert(getattr(result.packets[0], "transmitted_timestamp", None) is None) 97 | assert(getattr(result.packets[0], "transmitted_time", None) is None) 98 | assert(getattr(result.packets[0], "received_timestamp", None) is None) 99 | assert(getattr(result.packets[0], "received_time", None) is None) 100 | assert(result.packets[0].offset is None) 101 | assert(result.packets[0].rtt is None) 102 | 103 | 104 | def test_ntp_error(): 105 | result = ( 106 | '{ "msm_id":"1020235", "fw":4661, "lts":76, "timestamp":1418196642, "dst_name":"atlas", ' 107 | '"prb_id":71, "dst_addr":"193.0.6.139", "src_addr":"193.0.10.127", "proto":"UDP", "af": 4,' 108 | '"from": "193.0.0.78", "type": "ntp", "result": ' 109 | '[ { "error":"error-example" }, { "error":"error-example" }, { "x":"*" } ] ' 110 | '}' 111 | ) 112 | result = Result.get(result) 113 | assert(isinstance(result, NtpResult)) 114 | assert(result.af == 4) 115 | assert(result.firmware == 4661) 116 | assert(result.destination_address == "193.0.6.139") 117 | assert(result.destination_name == "atlas") 118 | assert(result.source_address == "193.0.10.127") 119 | assert(result.origin == "193.0.0.78") 120 | assert(result.leap_second_indicator is None) 121 | assert(result.stratum is None) 122 | assert(result.rtt_median is None) 123 | assert(result.offset_median is None) 124 | assert(getattr(result.packets[0], "final_timestamp", None) is None) 125 | assert(getattr(result.packets[0], "final_time", None) is None) 126 | assert(getattr(result.packets[0], "origin_timestamp", None) is None) 127 | assert(getattr(result.packets[0], "origin_time", None) is None) 128 | assert(getattr(result.packets[0], "transmitted_timestamp", None) is None) 129 | assert(getattr(result.packets[0], "transmitted_time", None) is None) 130 | assert(getattr(result.packets[0], "received_timestamp", None) is None) 131 | assert(getattr(result.packets[0], "received_time", None) is None) 132 | assert(result.packets[0].offset is None) 133 | assert(result.packets[0].rtt is None) 134 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/RIPEAtlasSagan.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/RIPEAtlasSagan.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/RIPEAtlasSagan" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/RIPEAtlasSagan" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\RIPEAtlasSagan.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\RIPEAtlasSagan.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | * 2.0.0 4 | * Official supported Python versions changed to 3.10, 3.11, 3.12 and 3.13 5 | * Removed test on invalid country codes in SSL certs, in line with the behaviour of the cryptography library 6 | * Reinstated integration with readthedocs.io 7 | * 1.3.1 8 | * Ping edge case fix 9 | * Add py37 testing 10 | * 1.3.0 11 | * abuf.py: error handling for NS records, extended rcode, cookies and client subnets 12 | * 1.2.2 13 | * Catch problems parsing SSL certificates 14 | * 1.2.1 15 | * Add support for non-DNS names in subjectAltName extensions 16 | * 1.2 17 | * Replaced pyOpenSSL with cryptography 18 | * Added parsing of subjectAltName X509 extension 19 | * 1.1.11 20 | * Added first version of WiFi results 21 | * 1.1.10 22 | * Added a `parse_all_hops` kwarg to the Traceroute class to tell Sagan to stop parsing Hops and Packets once we have all of the last hop statistics (default=True) 23 | * Remove dependency on IPy: we were using it for IPv6 canonicalization, but all IPv6 addresses in results should be in canonical form to start with. 24 | * 1.1.9 25 | * Removed the `parse_abuf` script because no one was using it and its 26 | Python3 support was suspect anyway. 27 | * 1.1.8 28 | * Handle case where a traceroute result might not have ``dst_addr`` field. 29 | * 1.1.7 30 | * Change condition of traceroute's ``last_hop_responded`` flag. 31 | * Add couple of more traceroute's properties. ``is_success`` and ``last_hop_errors``. 32 | * Add tests to the package itself. 33 | * 1.1.6 34 | * Fix for `Issue #56`_ a case where the ``qbuf`` value wasn't being properly 35 | captured. 36 | * Fixed small bug that didn't accurately capture the ``DO`` property from 37 | the qbuf. 38 | * 1.1.5 39 | * We now ignore so-called "late" packets in traceroute results. This will 40 | likely be amended later as future probe firmwares are expected to make 41 | better use of this value, but until then, Sagan will treat these packets 42 | as invalid. 43 | * 1.1.4 44 | * Added a ``type`` attribute to all ``Result`` subclasses 45 | * Added support for a lot of new DNS answer types, including ``NSEC``, 46 | ``PTR``, ``SRV``, and more. These answers do not yet have a complete 47 | string representation however. 48 | * 1.1.3 49 | * Changed the name of ``TracerouteResult.rtt_median`` to 50 | ``TracerouteResult.last_rtt_median``. 51 | * Modified the ``DnsResult`` class to allow the "bubbling up" of error 52 | statuses. 53 | * 1.1.2 54 | * We skipped this number for some reason :-/ 55 | * 1.1.1 56 | * Fixed a `string representation bug`_ found by `iortiz`_ 57 | * 1.1.0 58 | * **Breaking Change**: the ``Authority`` and ``Additional`` classes were 59 | removed, replaced with the appropriate answer types. For the most part, 60 | this change should be invisible, as the common properties are the same, 61 | but if you were testing code against these class types, you should 62 | consider this a breaking change. 63 | * **Breaking Change**: The ``__str__`` format for DNS ``RrsigAnswer`` to 64 | conform the output of a typical ``dig`` binary. 65 | * Added ``__str__`` definitions to DNS answer classes for use with the 66 | toolkit. 67 | * In an effort to make Sagan (along with Cousteau and the toolkit) more 68 | portable, we dropped the requirement for the ``arrow`` package. 69 | * 1.0.0 70 | * 1.0! w00t! 71 | * **Breaking Change**: the ``data`` property of the ``TxtAnswer`` class was 72 | changed from a string to a list of strings. This is a correction from 73 | our own past deviation from the RFC, so we thought it best to conform as 74 | part of the move to 1.0.0 75 | * Fixed a bug where non-ascii characters in DNS TXT answers resulted in an 76 | exception. 77 | * 0.8.2 78 | * Fixed a bug related to non-ascii characters in SSL certificate data. 79 | * Added a wrapper for json loaders to handle differences between ujson and 80 | the default json module. 81 | * 0.8.1 82 | * Minor fix to make all ``Result`` objects properly JSON serialisable. 83 | * 0.8.0 84 | * Added `iortiz`_'s patch for flags and ``flags`` 85 | and ``sections`` properties on DNS ``Answer`` objects. 86 | * 0.7.1 87 | * Changed ``README.md`` to ``README.rst`` to play nice with pypi. 88 | * 0.7 89 | * Added `pierky`_'s new ``RRSigAnswer`` class to 90 | the dns parser. 91 | * 0.6.3 92 | * Fixed a bug in how Sagan deals with inappropriate firmware versions 93 | * 0.6.2 94 | * Added `pierky`_'s fix to fix AD and CD flags 95 | parsing in DNS Header 96 | * 0.6.1 97 | * Added ``rtt_min``, ``rtt_max``, ``offset_min``, and ``offset_max`` to 98 | ``NTPResult`` 99 | * 0.6.0 100 | * Support for NTP measurements 101 | * Fixes for how we calculate median values 102 | * Smarter setup.py 103 | * 0.5.0 104 | * Complete Python3 support! 105 | * 0.4.0 106 | * Added better Python3 support. Tests all pass now for ping, traceroute, 107 | ssl, and http measurements. 108 | * Modified traceroute results to make use of ``destination_ip_responded`` 109 | and ``last_hop_responded``, deprecating ``target_responded``. See the 110 | docs for details. 111 | * 0.3.0 112 | * Added support for making use of some of the pre-calculated values in DNS 113 | measurements so you don't have to parse the abuf if you don't need it. 114 | * Fixed a bug in the abuf parser where a variable was being referenced by 115 | never defined. 116 | * Cleaned up some of the abuf parser to better conform to pep8. 117 | * 0.2.8 118 | * Fixed a bug where DNS ``TXT`` results with class ``IN`` were missing a 119 | ``.data`` value. 120 | * Fixed a problem in the SSL unit tests where ``\n`` was being 121 | misinterpreted. 122 | * 0.2.7 123 | * Made abuf more robust in dealing with truncation. 124 | * 0.2.6 125 | * Replaced ``SslResult.get_checksum_chain()`` with the 126 | ``SslResult.checksum_chain`` property. 127 | * Added support for catching results with an ``err`` property as an actual 128 | error. 129 | * 0.2.5 130 | * Fixed a bug in how the ``on_error`` and ``on_malformation`` preferences 131 | weren't being passed down into the subcomponents of the results. 132 | * 0.2.4 133 | * Support for ``seconds_since_sync`` across all measurement types 134 | * 0.2.3 135 | * "Treat a missing Type value in a DNS result as a malformation" (Issue #36) 136 | * 0.2.2 137 | * Minor bugfixes 138 | * 0.2.1 139 | * Added a ``median_rtt`` value to traceroute ``Hop`` objects. 140 | * Smarter and more consistent error handling in traceroute and HTTP 141 | results. 142 | * Added an ``error_message`` property to all objects that is set to ``None`` 143 | by default. 144 | * 0.2.0 145 | * Totally reworked error and malformation handling. We now differentiate 146 | between a result (or portion thereof) being malformed (and therefore 147 | unparsable) and simply containing an error such as a timeout. Look for 148 | an ``is_error`` property or an ``is_malformed`` property on every object 149 | to check for it, or simply pass ``on_malformation=Result.ACTION_FAIL`` if 150 | you'd prefer things to explode with an exception. See the documentation 151 | for more details 152 | * Added lazy-loading features for parsing abuf and qbuf values out of DNS 153 | results. 154 | * Removed the deprecated properties from ``dns.Response``. You must now 155 | access values like ``edns0`` from ``dns.Response.abuf.edns0``. 156 | * More edge cases have been found and accommodated. 157 | * 0.1.15 158 | * Added a bunch of abuf parsing features from 159 | `b4ldr`_ with some help from 160 | `phicoh`_. 161 | * 0.1.14 162 | * Fixed the deprecation warnings in ``DnsResult`` to point to the right 163 | place. 164 | * 0.1.13 165 | * Better handling of ``DNSResult`` errors 166 | * Rearranged the way abufs were handled in the ``DnsResult`` class to make 167 | way for ``qbuf`` values as well. The old method of accessing ``header``, 168 | ``answers``, ``questions``, etc is still available via ``Response``, but 169 | this will go away when we move to 0.2. Deprecation warnings are in place. 170 | * 0.1.12 171 | * Smarter code for checking whether the target was reached in 172 | ``TracerouteResults``. 173 | * We now handle the ``destination_option_size`` and 174 | ``hop_by_hop_option_size`` values in ``TracerouteResult``. 175 | * Extended support for ICMP header info in traceroute ``Hop`` class by 176 | introducing a new ``IcmpHeader`` class. 177 | * 0.1.8 178 | * Broader support for SSL checksums. We now make use of ``md5`` and 179 | ``sha1``, as well as the original ``sha256``. 180 | 181 | .. _Issue #56: https://github.com/RIPE-NCC/ripe.atlas.sagan/issues/56 182 | .. _string representation bug: https://github.com/RIPE-NCC/ripe-atlas-tools/issues/1 183 | .. _b4ldr: https://github.com/b4ldr 184 | .. _phicoh: https://github.com/phicoh 185 | .. _iortiz: https://github.com/iortiz 186 | .. _pierky: https://github.com/pierky 187 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/traceroute.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import logging 17 | 18 | from calendar import timegm 19 | 20 | from .base import Result, ParsingDict 21 | 22 | 23 | log = logging.getLogger(__name__) 24 | 25 | 26 | class IcmpHeader(ParsingDict): 27 | """ 28 | But why did we stop here? Why not go all the way and define subclasses for 29 | each object and for `mpls`? it comes down to a question of complexity vs. 30 | usefulness. This is such a fringe case that it's probably fine to just 31 | dump the data in to `self.objects` and let people work from there. If 32 | however you feel that this needs expansion, pull requests are welcome :-) 33 | 34 | Further information regarding the structure and meaning of the data in 35 | this class can be found here: http://localhost:8000/docs/data_struct/ 36 | """ 37 | 38 | def __init__(self, data, **kwargs): 39 | 40 | ParsingDict.__init__(self, **kwargs) 41 | 42 | self.raw_data = data 43 | 44 | self.version = self.ensure("version", int) 45 | self.rfc4884 = self.ensure("rfc4884", bool) 46 | self.objects = self.ensure("obj", list) 47 | 48 | 49 | class Packet(ParsingDict): 50 | 51 | ERROR_CONDITIONS = { 52 | "N": "Network unreachable", 53 | "H": "Destination unreachable", 54 | "A": "Administratively prohibited", 55 | "P": "Protocol unreachable", 56 | "p": "Port unreachable", 57 | } 58 | 59 | def __init__(self, data, **kwargs): 60 | 61 | ParsingDict.__init__(self, **kwargs) 62 | 63 | self.raw_data = data 64 | 65 | self.origin = self.ensure("from", str) 66 | self.rtt = self.ensure("rtt", float) 67 | self.size = self.ensure("size", int) 68 | self.ttl = self.ensure("ttl", int) 69 | self.mtu = self.ensure("mtu", int) 70 | self.destination_option_size = self.ensure("dstoptsize", int) 71 | self.hop_by_hop_option_size = self.ensure("hbhoptsize", int) 72 | self.arrived_late_by = self.ensure("late", int, 0) 73 | self.internal_ttl = self.ensure("ittl", int, 1) 74 | 75 | if self.rtt: 76 | self.rtt = round(self.rtt, 3) 77 | 78 | error = self.ensure("err", str) 79 | if error: 80 | self._handle_error(self.ERROR_CONDITIONS.get(error, error)) 81 | 82 | icmp_header = self.ensure("icmpext", dict) 83 | 84 | self.icmp_header = None 85 | if icmp_header: 86 | self.icmp_header = IcmpHeader(icmp_header, **kwargs) 87 | 88 | def __str__(self): 89 | return self.origin 90 | 91 | 92 | class Hop(ParsingDict): 93 | 94 | def __init__(self, data, **kwargs): 95 | 96 | ParsingDict.__init__(self, **kwargs) 97 | 98 | self.raw_data = data 99 | 100 | self.index = self.ensure("hop", int) 101 | 102 | error = self.ensure("error", str) 103 | if error: 104 | self._handle_error(error) 105 | 106 | self.packets = [] 107 | packet_rtts = [] 108 | if "result" in self.raw_data: 109 | for raw_packet in self.raw_data["result"]: 110 | if "late" not in raw_packet: 111 | packet = Packet(raw_packet, **kwargs) 112 | if packet.rtt: 113 | packet_rtts.append(packet.rtt) 114 | self.packets.append(packet) 115 | self.median_rtt = Result.calculate_median(packet_rtts) 116 | 117 | def __str__(self): 118 | return str(self.index) 119 | 120 | 121 | class TracerouteResult(Result): 122 | 123 | def __init__(self, data, **kwargs): 124 | 125 | Result.__init__(self, data, **kwargs) 126 | 127 | self.af = self.ensure("af", int) 128 | self.destination_address = self.ensure("dst_addr", str) 129 | self.destination_name = self.ensure("dst_name", str) 130 | self.source_address = self.ensure("src_addr", str) 131 | self.end_time = self.ensure("endtime", "datetime") 132 | self.paris_id = self.ensure("paris_id", int) 133 | self.size = self.ensure("size", int) 134 | 135 | if 0 < self.firmware < 4460: 136 | self.af = self.ensure("pf", int) 137 | 138 | self.protocol = self.clean_protocol(self.ensure("proto", str)) 139 | 140 | self.hops = [] 141 | self.total_hops = 0 142 | self.last_median_rtt = None 143 | 144 | # Used by a few response tests below 145 | self.destination_ip_responded = False 146 | self.last_hop_responded = False 147 | self.is_success = False 148 | self.last_hop_errors = [] 149 | 150 | self._parse_hops(**kwargs) # Sets hops, last_median_rtt, and total_hops 151 | 152 | @property 153 | def last_rtt(self): 154 | log.warning( 155 | '"last_rtt" is deprecated and will be removed in future versions. ' 156 | 'Instead, use "last_median_rtt".') 157 | return self.last_median_rtt 158 | 159 | @property 160 | def target_responded(self): 161 | log.warning( 162 | 'The "target_responded" property is deprecated and will be removed ' 163 | 'in future versions. Instead, use "destination_ip_responded".' 164 | ) 165 | return self.destination_ip_responded 166 | 167 | def set_destination_ip_responded(self, last_hop): 168 | """Sets the flag if destination IP responded.""" 169 | if not self.destination_address: 170 | return 171 | 172 | for packet in last_hop.packets: 173 | if packet.origin and \ 174 | self.destination_address == packet.origin: 175 | self.destination_ip_responded = True 176 | break 177 | 178 | def set_last_hop_responded(self, last_hop): 179 | """Sets the flag if last hop responded.""" 180 | for packet in last_hop.packets: 181 | if packet.rtt: 182 | self.last_hop_responded = True 183 | break 184 | 185 | def set_is_success(self, last_hop): 186 | """Sets the flag if traceroute result is successful or not.""" 187 | for packet in last_hop.packets: 188 | if packet.rtt and not packet.is_error: 189 | self.is_success = True 190 | break 191 | else: 192 | self.set_last_hop_errors(last_hop) 193 | 194 | def set_last_hop_errors(self, last_hop): 195 | """Sets the last hop's errors.""" 196 | if last_hop.is_error: 197 | self.last_hop_errors.append(last_hop.error_message) 198 | return 199 | 200 | for packet in last_hop.packets: 201 | if packet.is_error: 202 | self.last_hop_errors.append(packet.error_message) 203 | 204 | @property 205 | def end_time_timestamp(self): 206 | return timegm(self.end_time.timetuple()) 207 | 208 | @property 209 | def ip_path(self): 210 | """ 211 | Returns just the IPs from the traceroute. 212 | """ 213 | r = [] 214 | for hop in self.hops: 215 | r.append([packet.origin for packet in hop.packets]) 216 | return r 217 | 218 | def _parse_hops(self, parse_all_hops=True, **kwargs): 219 | 220 | try: 221 | hops = self.raw_data["result"] 222 | assert(isinstance(hops, list)) 223 | except (KeyError, AssertionError): 224 | self._handle_malformation("Legacy formats not supported") 225 | return 226 | 227 | num_hops = len(hops) 228 | # Go through the hops in reverse so that if 229 | # parse_all_hops is False we can stop processing as 230 | # soon as possible. 231 | for index, raw_hop in reversed(list(enumerate(hops))): 232 | 233 | hop = Hop(raw_hop, **kwargs) 234 | 235 | # If last hop set several useful attributes 236 | if index + 1 == num_hops: 237 | self.set_destination_ip_responded(hop) 238 | self.set_last_hop_responded(hop) 239 | self.set_is_success(hop) 240 | # We always store the last hop 241 | self.hops.insert(0, hop) 242 | elif parse_all_hops: 243 | self.hops.insert(0, hop) 244 | 245 | if hop.median_rtt and not self.last_median_rtt: 246 | self.last_median_rtt = hop.median_rtt 247 | if not parse_all_hops: 248 | # Now that we have the last RTT we can stop 249 | break 250 | self.total_hops = num_hops 251 | 252 | 253 | __all__ = ( 254 | "TracerouteResult", 255 | ) 256 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | RIPE Atlas Sagan 2 | =============================================== 3 | 4 | |Documentation| |PYPI Version| |Python Versions| 5 | 6 | A parsing library for RIPE Atlas measurement results 7 | 8 | Why this exists 9 | --------------- 10 | 11 | RIPE Atlas generates a **lot** of data, and the format of that data changes over 12 | time. Often you want to do something simple like fetch the median RTT for each 13 | measurement result between date ``X`` and date ``Y``. Unfortunately, there are 14 | dozens of edge cases to account for while parsing the JSON, like the format of 15 | errors and firmware upgrades that changed the format entirely. 16 | 17 | To make this easier for our users (and for ourselves), we wrote an easy to use 18 | parser that's smart enough to figure out the best course of action for each 19 | result, and return to you a useful, native Python object. 20 | 21 | How to install 22 | -------------- 23 | 24 | The stable version should always be in PyPi, so you can install it with ``pip``: 25 | 26 | .. code:: bash 27 | 28 | $ pip install ripe.atlas.sagan 29 | 30 | Better yet, make sure you get ujson and sphinx installed with it: 31 | 32 | .. code:: bash 33 | 34 | $ pip install ripe.atlas.sagan[fast,doc] 35 | 36 | Quickstart: How To Use It 37 | ------------------------- 38 | 39 | You can parse a result in a few ways. You can just pass the JSON-encoded string: 40 | 41 | .. code:: python 42 | 43 | from ripe.atlas.sagan import PingResult 44 | 45 | my_result = PingResult("") 46 | 47 | print(my_result.rtt_median) 48 | 123.456 49 | 50 | print(my_result.af) 51 | 6 52 | 53 | You can do the JSON-decoding yourself: 54 | 55 | .. code:: python 56 | 57 | from ripe.atlas.sagan import PingResult 58 | 59 | my_result = PingResult( 60 | json.loads("") 61 | ) 62 | 63 | print(my_result.rtt_median) 64 | 123.456 65 | 66 | print(my_result.af) 67 | 6 68 | 69 | You can let the parser guess the right type for you, though this incurs a small 70 | performance penalty: 71 | 72 | .. code:: python 73 | 74 | from ripe.atlas.sagan import Result 75 | 76 | my_result = Result.get("") 77 | 78 | print(my_result.rtt_median) 79 | 123.456 80 | 81 | print(my_result.af) 82 | 6 83 | 84 | What it supports 85 | ---------------- 86 | 87 | Essentially, we tried to support everything. If you pass in a DNS result string, 88 | the parser will return a ``DNSResult`` object, which contains a list of 89 | ``Response``'s, each with an ``abuf`` property, as well as all of the 90 | information in that abuf: header, question, answer, etc. 91 | 92 | .. code:: python 93 | 94 | from ripe.atlas.sagan import DnsResult 95 | 96 | my_dns_result = DnsResult("") 97 | my_dns_result.responses[0].abuf # The entire string 98 | my_dns_result.responses[0].abuf.header.arcount # Decoded from the abuf 99 | 100 | We do the same sort of thing for SSL measurements, traceroutes, everything. We 101 | try to save you the effort of sorting through whatever is in the result. 102 | 103 | Which attributes are supported? 104 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 105 | 106 | Every result type has its own properties, with a few common between all types. 107 | 108 | Specifically, these attributes exist on all ``*Result`` objects: 109 | 110 | - ``created`` An datetime object of the 111 | ``timestamp`` field 112 | - ``measurement_id`` 113 | - ``probe_id`` 114 | - ``firmware`` An integer representing the firmware version 115 | - ``origin`` The ``from`` attribute in the result 116 | - ``is_error`` Set to ``True`` if an error was found 117 | 118 | Additionally, each of the result types have their own properties, like 119 | ``packet_size``, ``responses``, ``certificates``, etc. You can take a look at 120 | the classes themselves, or just look at the tests if you're curious. But to get 121 | you started, here are some examples: 122 | 123 | .. code:: python 124 | 125 | # Ping 126 | ping_result.packets_sent # Int 127 | ping_result.rtt_median # Float, rounded to 3 decimal places 128 | ping_result.rtt_average # Float, rounded to 3 decimal places 129 | 130 | # Traceroute 131 | traceroute_result.af # 4 or 6 132 | traceroute_result.total_hops # Int 133 | traceroute_result.destination_address # An IP address string 134 | 135 | # DNS 136 | dns_result.responses # A list of Response objects 137 | dns_result.responses[0].response_time # Float, rounded to 3 decimal places 138 | dns_result.responses[0].headers # A list of Header objects 139 | dns_result.responses[0].headers[0].nscount # The NSCOUNT value for the first header 140 | dns_result.responses[0].questions # A list of Question objects 141 | dns_result.responses[0].questions[0].type # The TYPE value for the first question 142 | dns_result.responses[0].abuf # The raw, unparsed abuf string 143 | 144 | # SSL Certificates 145 | ssl_result.af # 4 or 6 146 | ssl_result.certificates # A list of Certificate objects 147 | ssl_result.certificates[0].checksum # The checksum for the first certificate 148 | 149 | # HTTP 150 | http_result.af # 4 or 6 151 | http_result.uri # A URL string 152 | http_result.responses # A list of Response objects 153 | http_result.responses[0].body_size # The size of the body of the first response 154 | 155 | # NTP 156 | ntp_result.af # 4 or 6 157 | ntp_result.stratum # Statum id 158 | ntp_result.version # Version number 159 | ntp_result.packets[0].final_timestamp # A float representing a high-precision NTP timestamp 160 | ntp_result.rtt_median # Median value for packets sent & received 161 | 162 | What it requires 163 | ---------------- 164 | 165 | As you might have guessed, with all of this magic going on under the hood, there 166 | are a few dependencies: 167 | 168 | - `cryptography`_ (Optional: see "Troubleshooting" above) 169 | - `python-dateutil`_ 170 | - `pytz`_ 171 | - `IPy`_ 172 | 173 | Additionally, we recommend that you also install `ujson`_ as it will speed up 174 | the JSON-decoding step considerably, and `sphinx`_ if you intend to build the 175 | documentation files for offline use. 176 | 177 | Running Tests 178 | ------------- 179 | 180 | There's a full battery of tests for all measurement types, so if you've made 181 | changes and would like to submit a pull request, please run them (and update 182 | them!) before sending your request: 183 | 184 | .. code:: bash 185 | 186 | $ python setup.py test 187 | 188 | You can also install ``tox`` to test everything in all of the supported Python 189 | versions: 190 | 191 | .. code:: bash 192 | 193 | $ pip install tox 194 | $ tox 195 | 196 | Further Documentation 197 | --------------------- 198 | 199 | Complete documentation can always be found on `Read the Docs`_, 200 | and if you're not online, the project itself contains a ``docs`` directory -- 201 | everything you should need is in there. 202 | 203 | 204 | Who's Responsible for This? 205 | --------------------------- 206 | 207 | Sagan is actively maintained by the RIPE NCC and primarily developed by `Daniel 208 | Quinn`_, while the abuf parser is mostly the responsibility of `Philip Homburg`_ 209 | with an assist from Bert Wijnen and Rene Wilhelm who contributed to the original 210 | script. `Andreas Stirkos`_ did the bulk of the work on NTP measurements and 211 | fixed a few bugs, and big thanks go to `Chris Amin`_, `John Bond`_, and 212 | `Pier Carlo Chiodi`_ for finding and fixing stuff where they've run into 213 | problems. 214 | 215 | 216 | Colophon 217 | -------- 218 | 219 | But why "`Sagan`_"? The RIPE Atlas team decided to name all of its modules after 220 | explorers, and what better name for a parser than that of the man who spent 221 | decades reaching out to the public about the wonders of the cosmos? 222 | 223 | .. _python-dateutil: https://pypi.python.org/pypi/python-dateutil 224 | .. _cryptography: https://pypi.python.org/pypi/cryptography 225 | .. _pytz: https://pypi.python.org/pypi/pytz 226 | .. _IPy: https://pypi.python.org/pypi/IPy/ 227 | .. _ujson: https://pypi.python.org/pypi/ujson 228 | .. _sphinx: https://pypi.python.org/pypi/Sphinx 229 | .. _Read the Docs: http://ripe-atlas-sagan.readthedocs.org/en/latest/ 230 | .. _Daniel Quinn: https://github.com/danielquinn 231 | .. _Philip Homburg: https://github.com/philiphomburg 232 | .. _Andreas Stirkos: https://github.com/astrikos 233 | .. _Chris Amin: https://github.com/chrisamin 234 | .. _John Bond: https://github.com/b4ldr 235 | .. _Pier Carlo Chiodi: https://github.com/pierky 236 | .. _Sagan: https://en.wikipedia.org/wiki/Carl_Sagan 237 | .. |Documentation| image:: https://readthedocs.org/projects/ripe-atlas-sagan/badge/?version=latest 238 | :target: http://ripe-atlas-sagan.readthedocs.org/en/latest/?badge=latest 239 | :alt: Documentation Status 240 | .. |PYPI Version| image:: https://img.shields.io/pypi/v/ripe.atlas.sagan.svg 241 | :target: https://pypi.org/project/ripe.atlas.sagan/ 242 | .. |Python Versions| image:: https://img.shields.io/pypi/pyversions/ripe.atlas.sagan.svg 243 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/ssl.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import logging 17 | import pytz 18 | import codecs 19 | 20 | from datetime import datetime 21 | 22 | try: 23 | from cryptography import x509 24 | from cryptography.x509.oid import NameOID 25 | from cryptography.hazmat.backends import openssl 26 | from cryptography.hazmat.primitives import hashes 27 | except ImportError: 28 | logging.getLogger(__name__).warning( 29 | "cryptography module is not installed, without it you cannot parse SSL " 30 | "certificate measurement results" 31 | ) 32 | 33 | from .base import Result, ParsingDict 34 | from .helpers.compatibility import string 35 | 36 | 37 | EXT_SAN = "subjectAltName" 38 | 39 | 40 | class Certificate(ParsingDict): 41 | 42 | def __init__(self, data, **kwargs): 43 | 44 | ParsingDict.__init__(self, **kwargs) 45 | 46 | self.raw_data = data 47 | self.subject_cn = None 48 | self.subject_o = None 49 | self.subject_c = None 50 | self.issuer_cn = None 51 | self.issuer_o = None 52 | self.issuer_c = None 53 | 54 | self.valid_from = None 55 | self.valid_until = None 56 | 57 | self.checksum_md5 = None 58 | self.checksum_sha1 = None 59 | self.checksum_sha256 = None 60 | 61 | self.has_expired = None 62 | 63 | self.extensions = {} 64 | 65 | cert = x509.load_pem_x509_certificate(data.encode("ascii"), openssl.backend) 66 | 67 | if cert: 68 | self.checksum_md5 = self._colonify(cert.fingerprint(hashes.MD5())) 69 | self.checksum_sha1 = self._colonify(cert.fingerprint(hashes.SHA1())) 70 | self.checksum_sha256 = self._colonify(cert.fingerprint(hashes.SHA256())) 71 | 72 | self.valid_from = pytz.utc.localize(cert.not_valid_before) 73 | self.valid_until = pytz.utc.localize(cert.not_valid_after) 74 | 75 | self.has_expired = self._has_expired() 76 | 77 | self._add_extensions(cert) 78 | 79 | if cert and cert.subject: 80 | self.subject_cn, self.subject_o, self.subject_c = \ 81 | self._parse_x509_name(cert.subject) 82 | 83 | if cert and cert.issuer: 84 | self.issuer_cn, self.issuer_o, self.issuer_c = \ 85 | self._parse_x509_name(cert.issuer) 86 | 87 | # OID name lookup of the common abbreviations 88 | # In reality probably only CN will be used 89 | _oid_names = { 90 | NameOID.COMMON_NAME: "CN", 91 | NameOID.ORGANIZATION_NAME: "O", 92 | NameOID.ORGANIZATIONAL_UNIT_NAME: "OU", 93 | NameOID.COUNTRY_NAME: "C", 94 | NameOID.STATE_OR_PROVINCE_NAME: "S", 95 | NameOID.LOCALITY_NAME: "L", 96 | } 97 | 98 | def _get_oid_name(self, oid): 99 | return self._oid_names.get(oid, oid.dotted_string) 100 | 101 | def _name_attribute_to_string(self, name): 102 | """ 103 | Build a /-separated string from an x509.Name. 104 | """ 105 | return "".join( 106 | "/{}={}".format( 107 | self._get_oid_name(attr.oid), 108 | attr.value, 109 | ) 110 | for attr in name 111 | ) 112 | 113 | def _get_subject_alternative_names(self, ext): 114 | """ 115 | Return a list of Subject Alternative Name values for the given x509 116 | extension object. 117 | """ 118 | values = [] 119 | for san in ext.value: 120 | if isinstance(san.value, string): 121 | # Pass on simple string SAN values 122 | values.append(san.value) 123 | elif isinstance(san.value, x509.Name): 124 | # In theory there there could be >1 RDN here... 125 | values.extend( 126 | self._name_attribute_to_string(rdn) for rdn in san.value.rdns 127 | ) 128 | return values 129 | 130 | def _add_extensions(self, cert): 131 | for ext in cert.extensions: 132 | if ext.oid._name == EXT_SAN: 133 | self.extensions[EXT_SAN] = self._get_subject_alternative_names(ext) 134 | 135 | @staticmethod 136 | def _colonify(bytes): 137 | hex = codecs.getencoder("hex_codec")(bytes)[0].decode("ascii").upper() 138 | return ":".join(a+b for a, b in zip(hex[::2], hex[1::2])) 139 | 140 | @staticmethod 141 | def _parse_x509_name(name): 142 | cn = None 143 | o = None 144 | c = None 145 | for attr in name: 146 | if attr.oid == NameOID.COUNTRY_NAME: 147 | c = attr.value 148 | elif attr.oid == NameOID.ORGANIZATION_NAME: 149 | o = attr.value 150 | elif attr.oid == NameOID.COMMON_NAME: 151 | cn = attr.value 152 | return cn, o, c 153 | 154 | def _has_expired(self): 155 | now = pytz.utc.localize(datetime.utcnow()) 156 | return self.valid_from <= now <= self.valid_until 157 | 158 | @property 159 | def cn(self): 160 | return self.subject_cn 161 | 162 | @property 163 | def o(self): 164 | return self.subject_o 165 | 166 | @property 167 | def c(self): 168 | return self.subject_c 169 | 170 | @property 171 | def common_name(self): 172 | return self.cn 173 | 174 | @property 175 | def organisation(self): 176 | return self.o 177 | 178 | @property 179 | def country(self): 180 | return self.c 181 | 182 | @property 183 | def checksum(self): 184 | return self.checksum_sha256 185 | 186 | 187 | class Alert(ParsingDict): 188 | 189 | # Taken from https://tools.ietf.org/html/rfc5246#section-7.2 190 | DESCRIPTION_MAP = { 191 | 0: "close_notify", 192 | 10: "unexpected_message", 193 | 20: "bad_record_mac", 194 | 21: "decryption_failed_RESERVED", 195 | 22: "record_overflow", 196 | 30: "decompression_failure", 197 | 40: "handshake_failure", 198 | 41: "no_certificate_RESERVED", 199 | 42: "bad_certificate", 200 | 43: "unsupported_certificate", 201 | 44: "certificate_revoked", 202 | 45: "certificate_expired", 203 | 46: "certificate_unknown", 204 | 47: "illegal_parameter", 205 | 48: "unknown_ca", 206 | 49: "access_denied", 207 | 50: "decode_error", 208 | 51: "decrypt_error", 209 | 60: "export_restriction_RESERVED", 210 | 70: "protocol_version", 211 | 71: "insufficient_security", 212 | 80: "internal_error", 213 | 90: "user_canceled", 214 | 100: "no_renegotiation", 215 | 110: "unsupported_extension", 216 | } 217 | 218 | def __init__(self, data, **kwargs): 219 | 220 | ParsingDict.__init__(self, **kwargs) 221 | 222 | self.raw_data = data 223 | 224 | self.level = self.ensure("level", int) 225 | self.description = self.ensure("decription", int) 226 | if self.description is None: 227 | self.description = self.ensure("description", int) 228 | 229 | @property 230 | def description_string(self): 231 | return self.DESCRIPTION_MAP.get(self.description, "Unknown") 232 | 233 | 234 | class SslResult(Result): 235 | 236 | def __init__(self, data, **kwargs): 237 | 238 | Result.__init__(self, data, **kwargs) 239 | 240 | self.af = self.ensure("af", int) 241 | self.destination_address = self.ensure("dst_addr", str) 242 | self.destination_name = self.ensure("dst_name", str) 243 | self.source_address = self.ensure("src_addr", str) 244 | self.port = self.ensure("dst_port", int) 245 | self.method = self.ensure("method", str) 246 | self.version = self.ensure("ver", str) 247 | self.response_time = self.ensure("rt", float) 248 | self.time_to_connect = self.ensure("ttc", float) 249 | 250 | if "error" in self.raw_data: 251 | self._handle_error(self.raw_data["error"]) 252 | 253 | # Older versions used named ports 254 | if self.port is None and self.raw_data.get("dst_port") == "https": 255 | self.port = 443 256 | 257 | self.alert = None 258 | self.certificates = [] 259 | self.is_self_signed = False 260 | 261 | if "alert" in self.raw_data: 262 | self.alert = Alert(self.raw_data["alert"], **kwargs) 263 | self._handle_error(self.alert.description_string) 264 | 265 | if "cert" in self.raw_data and isinstance(self.raw_data["cert"], list): 266 | 267 | for certificate in self.raw_data["cert"]: 268 | try: 269 | self.certificates.append(Certificate(certificate, **kwargs)) 270 | except Exception as exc: 271 | self._handle_error(str(exc)) 272 | continue 273 | 274 | if len(self.certificates) == 1: 275 | certificate = self.certificates[0] 276 | if certificate.subject_cn == certificate.issuer_cn: 277 | self.is_self_signed = True 278 | 279 | @property 280 | def checksum_chain(self): 281 | """ 282 | Returns a list of checksums joined with "::". 283 | """ 284 | 285 | checksums = [] 286 | for certificate in self.certificates: 287 | checksums.append(certificate.checksum) 288 | 289 | return "::".join(checksums) 290 | 291 | 292 | __all__ = ( 293 | "SslResult" 294 | ) 295 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import logging 17 | import pytz 18 | 19 | from calendar import timegm 20 | from datetime import datetime 21 | 22 | from .helpers.compatibility import string 23 | 24 | # Try to use ujson if it's available 25 | try: 26 | import ujson as json 27 | except ImportError: 28 | import json 29 | 30 | 31 | log = logging.getLogger(__name__) 32 | 33 | 34 | class ResultParseError(Exception): 35 | pass 36 | 37 | 38 | class ResultError(Exception): 39 | pass 40 | 41 | 42 | class Json(object): 43 | """ 44 | ujson, while impressive, is not a drop-in replacement for json as it doesn't 45 | respect the various keyword arguments permitted in the default json parser. 46 | As a workaround for this, we have our own class that defines its own 47 | .loads() method, so we can check for whichever we're using and adjust the 48 | arguments accordingly. 49 | """ 50 | 51 | @staticmethod 52 | def loads(*args, **kwargs): 53 | try: 54 | if json.__name__ == "ujson": 55 | return json.loads(*args, **kwargs) 56 | return json.loads(strict=False, *args, **kwargs) 57 | except ValueError: 58 | raise ResultParseError("The JSON result could not be parsed") 59 | 60 | 61 | class ParsingDict(object): 62 | """ 63 | A handy container for methods we use for validation in the various result 64 | classes. 65 | 66 | Note that Python 2.x and 3.x handle the creation of dictionary-like objects 67 | differently. If we write it this way, it works for both. 68 | """ 69 | 70 | ACTION_IGNORE = 1 71 | ACTION_WARN = 2 72 | ACTION_FAIL = 3 73 | 74 | PROTOCOL_ICMP = "ICMP" 75 | PROTOCOL_UDP = "UDP" 76 | PROTOCOL_TCP = "TCP" 77 | PROTOCOL_MAP = { 78 | "ICMP": PROTOCOL_ICMP, 79 | "I": PROTOCOL_ICMP, 80 | "UDP": PROTOCOL_UDP, 81 | "U": PROTOCOL_UDP, 82 | "TCP": PROTOCOL_TCP, 83 | "T": PROTOCOL_TCP, 84 | } 85 | 86 | def __init__(self, **kwargs): 87 | 88 | self._on_error = kwargs.pop("on_error", self.ACTION_WARN) 89 | self.is_error = False 90 | self.error_message = None 91 | 92 | self._on_malformation = kwargs.pop("on_malformation", self.ACTION_WARN) 93 | self.is_malformed = False 94 | 95 | def __nonzero__(self): 96 | # If we don't define this, Python ends up calling keys() 97 | # via __len__() whenever we evaluate the object as a bool. 98 | return True 99 | 100 | def __len__(self): 101 | return len(self.keys()) 102 | 103 | def __iter__(self): 104 | for key in self.keys(): 105 | yield getattr(self, key) 106 | 107 | def __getitem__(self, key): 108 | return getattr(self, key) 109 | 110 | def __setitem__(self, key, item): 111 | setattr(self, key, item) 112 | 113 | def keys(self): 114 | return [p for p in dir(self) if self._is_property_name(p)] 115 | 116 | def ensure(self, key, kind, default=None): 117 | try: 118 | if kind == "datetime": 119 | return datetime.fromtimestamp( 120 | self.raw_data[key], tz=pytz.UTC) 121 | return kind(self.raw_data[key]) 122 | except (TypeError, ValueError, KeyError): 123 | return default 124 | 125 | def clean_protocol(self, protocol): 126 | """ 127 | A lot of measurement types make use of a protocol value, so we handle 128 | that here. 129 | """ 130 | if protocol is not None: 131 | try: 132 | return self.PROTOCOL_MAP[protocol] 133 | except KeyError: 134 | self._handle_malformation( 135 | '"{protocol}" is not a recognised protocol'.format( 136 | protocol=protocol 137 | ) 138 | ) 139 | 140 | def _handle_malformation(self, message): 141 | if self._on_malformation == self.ACTION_FAIL: 142 | raise ResultParseError(message) 143 | elif self._on_malformation == self.ACTION_WARN: 144 | log.warning(message) 145 | self.is_malformed = True 146 | 147 | def _handle_error(self, message): 148 | if self._on_error == self.ACTION_FAIL: 149 | raise ResultError(message) 150 | elif self._on_error == self.ACTION_WARN: 151 | log.warning(message) 152 | self.is_error = True 153 | self.error_message = message 154 | 155 | def _is_property_name(self, p): 156 | if not p.startswith("_"): 157 | if p not in ("keys",): 158 | if not p.upper() == p: 159 | if not callable(getattr(self, p)): 160 | return True 161 | return False 162 | 163 | 164 | class Result(ParsingDict): 165 | """ 166 | The parent class for all measurement result classes. Subclass this to 167 | handle parsing a new measurement type, or use .get() to let this class 168 | figure out the type for you. 169 | """ 170 | 171 | def __init__(self, data, *args, **kwargs): 172 | 173 | ParsingDict.__init__(self, **kwargs) 174 | 175 | self.raw_data = data 176 | if isinstance(data, string): 177 | self.raw_data = Json.loads(data) 178 | 179 | for key in ("timestamp", "msm_id", "prb_id", "fw", "type"): 180 | if key not in self.raw_data: 181 | raise ResultParseError( 182 | "This doesn't look like a RIPE Atlas measurement: {}".format( 183 | self.raw_data 184 | ) 185 | ) 186 | 187 | self.created = datetime.fromtimestamp( 188 | self.raw_data["timestamp"], tz=pytz.UTC) 189 | 190 | self.measurement_id = self.ensure("msm_id", int) 191 | self.probe_id = self.ensure("prb_id", int) 192 | self.firmware = self.ensure("fw", int) 193 | self.origin = self.ensure("from", str) 194 | self.seconds_since_sync = self.ensure("lts", int) 195 | self.group_id = self.ensure("group_id", int) 196 | self.bundle = self.ensure("bundle", int) 197 | 198 | # Handle the weird case where fw=0 and we don't know what to expect 199 | if self.firmware == 0: 200 | self._handle_malformation("Unknown firmware: {fw}".format( 201 | fw=self.firmware) 202 | ) 203 | 204 | if self.seconds_since_sync is not None: 205 | if self.seconds_since_sync < 0: 206 | self.seconds_since_sync = None 207 | 208 | if "dnserr" in self.raw_data: 209 | self._handle_error(self.raw_data["dnserr"]) 210 | 211 | if "err" in self.raw_data: 212 | self._handle_error(self.raw_data["err"]) 213 | 214 | def __repr__(self): 215 | return "Measurement #{measurement}, Probe #{probe}".format( 216 | measurement=self.measurement_id, 217 | probe=self.probe_id 218 | ) 219 | 220 | @property 221 | def created_timestamp(self): 222 | return timegm(self.created.timetuple()) 223 | 224 | @classmethod 225 | def get(cls, data, **kwargs): 226 | """ 227 | Call this when you have a JSON result and just want to turn it into the 228 | appropriate Result subclass. This is less performant than calling 229 | PingResult(json_string) directly however, as the JSON has to be parsed 230 | first to find the type. 231 | """ 232 | 233 | raw_data = data 234 | if isinstance(data, string): 235 | raw_data = Json.loads(data) 236 | 237 | try: 238 | kind = raw_data["type"].lower() 239 | except KeyError: 240 | raise ResultParseError("No type value was found in the JSON input") 241 | 242 | if kind == "ping": 243 | from .ping import PingResult 244 | return PingResult(raw_data, **kwargs) 245 | elif kind == "traceroute": 246 | from .traceroute import TracerouteResult 247 | return TracerouteResult(raw_data, **kwargs) 248 | elif kind == "dns": 249 | from .dns import DnsResult 250 | return DnsResult(raw_data, **kwargs) 251 | elif kind == "sslcert": 252 | from .ssl import SslResult 253 | return SslResult(raw_data, **kwargs) 254 | elif kind == "http": 255 | from .http import HttpResult 256 | return HttpResult(raw_data, **kwargs) 257 | elif kind == "ntp": 258 | from .ntp import NtpResult 259 | return NtpResult(raw_data, **kwargs) 260 | elif kind == "wifi": 261 | from .wifi import WiFiResult 262 | return WiFiResult(raw_data, **kwargs) 263 | 264 | raise ResultParseError("Unknown type value was found in the JSON input") 265 | 266 | @staticmethod 267 | def calculate_median(given_list): 268 | """ 269 | Returns the median of values in the given list. 270 | """ 271 | median = None 272 | 273 | if not given_list: 274 | return median 275 | 276 | given_list = sorted(given_list) 277 | list_length = len(given_list) 278 | 279 | if list_length % 2: 280 | median = given_list[int(list_length / 2)] 281 | else: 282 | median = (given_list[int(list_length / 2)] + given_list[int(list_length / 2) - 1]) / 2.0 283 | 284 | return median 285 | 286 | @property 287 | def type(self): 288 | return self.__class__.__name__.replace("Result", "").lower() 289 | 290 | 291 | __all__ = ( 292 | "Result", 293 | "ResultParseError", 294 | ) 295 | -------------------------------------------------------------------------------- /docs/use.rst: -------------------------------------------------------------------------------- 1 | .. _use-and-examples: 2 | 3 | Use & Examples 4 | ************** 5 | 6 | The library contains a full test suite for each measurement type, so if you're 7 | looking for examples, it's a good idea to start there. For this document we'll 8 | cover basic usage and some simple examples to get you started. 9 | 10 | 11 | .. _use: 12 | 13 | How To Use This Library 14 | ======================= 15 | 16 | Sagan's sole purpose is to make RIPE Atlas measurements manageable from within 17 | Python. You shouldn't have to be fiddling with JSON, or trying to find values 18 | that changed locations between firmware versions. Instead, you should always 19 | be able to pass in the JSON string and immediately get usable Python objects. 20 | 21 | 22 | .. _use-basics: 23 | 24 | Important Note 25 | -------------- 26 | 27 | The one thing that tends to confuse people when first trying out Sagan is that 28 | this library operates on **single measurement results**, and not a list of 29 | results. If you have a list of results (for example, the output of the 30 | measurement results API), then you must loop over those results and pass each 31 | result to Sagan for parsing. 32 | 33 | Basics 34 | ------ 35 | 36 | To that end, the interface is pretty simple. If you have a ping measurement 37 | result, then use the PingResult class to make use of the data:: 38 | 39 | from ripe.atlas.sagan import PingResult 40 | 41 | my_result = PingResult('this is where your big JSON blob goes') 42 | 43 | my_result.af 44 | # Returns 6 45 | 46 | my_result.rtt_median 47 | # Returns 123.456 48 | 49 | Note that ``rtt_median`` isn't actually in the JSON data passed in. It's 50 | calculated during the parsing phase so you don't need to fiddle with looping 51 | over attributes in a list and doing the math yourself. 52 | 53 | 54 | .. _use-plain-text-not-required: 55 | 56 | Plain Text Not Required 57 | ----------------------- 58 | 59 | It should be noted that while all of the examples here use a plain text string 60 | for our results, Sagan doesn't force you to pass in a string. It's just as 61 | happy with a Python dict, the result of already running your result string 62 | through ``json.loads()``:: 63 | 64 | import json 65 | from ripe.atlas.sagan import PingResult 66 | 67 | my_result_dict = json.loads('this is where your big JSON blob goes') 68 | my_result = PingResult(my_result_dict) 69 | 70 | my_result.af 71 | # Returns 6 72 | 73 | my_result.rtt_median 74 | # Returns 123.456 75 | 76 | 77 | .. _use-agnostic-parsing: 78 | 79 | Agnostic Parsing 80 | ---------------- 81 | 82 | There may be a case where you have code that's just expected to parse a result 83 | string, without knowing ahead of time what type of result it is. For this we 84 | make use of the parent ``Result`` class' ``get()`` method:: 85 | 86 | from ripe.atlas.sagan import Result 87 | 88 | my_result = Result.get('this is where your big JSON blob goes') 89 | 90 | my_result.af 91 | # Returns 6 92 | 93 | my_result.rtt_median 94 | # Returns 123.456 95 | 96 | As you can see it works just like PingResult, but doesn't force you to know its 97 | type up front. Note that this does incur a small performance penalty however. 98 | 99 | 100 | .. _use-errors-and-malformations: 101 | 102 | Errors & Malformations 103 | ---------------------- 104 | 105 | RIPE Atlas, like the Internet is never 100% what you'd expect. Sometimes your 106 | measurement will return an error such as a timout or DNS lookup problem, and 107 | sometimes the data in a result might even be malformed on account of data 108 | corruption, damaged probe storage, etc. 109 | 110 | And like the most applications on the Internet, Sagan attemps to handle these 111 | inconsistencies gracefully. You can decide just how gracefully however. 112 | 113 | Say for example you've got a result that looks alright, but the ``abuf`` value 114 | is damaged in some way rendering it unreadable. You'll find that while the 115 | ``DnsResult`` object will not have a ``is_malformed=False``, the portion that is 116 | unreadable will be set to ``True``:: 117 | 118 | from ripe.atlas.sagan import DnsResult 119 | my_result = DnsResult('your JSON blob') 120 | 121 | my_result.is_error # False 122 | my_result.is_malformed # False 123 | my_result.responses[0].abuf.is_malformed # True 124 | my_result.responses[1].abuf.is_malformed # False 125 | 126 | You can control what you'd like Sagan to do in these cases by setting 127 | ``on_malformation=`` when parsing:: 128 | 129 | from ripe.atlas.sagan import DnsResult 130 | 131 | # Sets is_malformed=True and issues a warning 132 | my_result = DnsResult('your JSON blob') 133 | 134 | # Sets is_malformed=True 135 | my_result = DnsResult('your JSON blob', on_malformation=DnsResult.ACTION_IGNORE) 136 | 137 | # Sets explodes with a ResultParseError 138 | my_result = DnsResult('your JSON blob', on_malformation=DnsResult.ACTION_FAIL) 139 | 140 | Similarly, you can do the same thing with ``on_error=``, which perform the same 141 | way when Sagan encounters an error like a timeout or DNS lookup problem. 142 | 143 | Error handling is not yet complete in Sagan, so if you run across a case where 144 | it behaves in a way other than what you'd expect, please send a copy of the 145 | problematic result to atlas@ripe.net and we'll use it to update this library. 146 | 147 | 148 | .. _examples: 149 | 150 | Examples 151 | ======== 152 | 153 | .. _examples-file: 154 | 155 | Parsing Results out of a Local File 156 | ----------------------------------- 157 | 158 | Assume for a moment that you've downloaded a bunch of results into a local file 159 | using our *fragmented JSON* format. That is, you have in your possession a file 160 | that has a separate JSON result on every line. For the purposes of our example 161 | we'll call it ``file.txt``.:: 162 | 163 | from ripe.atlas.sagan import Result 164 | 165 | my_results_file = "/path/to/file.txt" 166 | with open(my_results_file) as results: 167 | for result in results.readlines(): 168 | parsed_result = Result.get(result) 169 | print(parsed_result.origin) 170 | 171 | Basically you use Python to open the file (using ``with``) and then loop over 172 | each line in the file (``.readlines()``), sending each line into Sagan which 173 | returns a ``parsed_result``. With that result, you can then pull out any of 174 | the values you like, using the :ref:`attributes-methods` documentation as a 175 | reference. 176 | 177 | 178 | .. _examples-api: 179 | 180 | Pulling Directly from the API 181 | ----------------------------- 182 | 183 | A common use case for the parser is to plug it into our RESTful API service. 184 | The process for this is pretty simple: fetch a bunch of results, loop over them, 185 | and for each one, apply the parser to get the value you want. 186 | 187 | Say for example you want to get the ``checksum`` value for each result from 188 | measurement `#1012449`_. To do this, we'll fetch the latest results from each 189 | probe via the ``measurement-latest`` API, and parse each one to get the 190 | checksum values:: 191 | 192 | import requests 193 | from ripe.atlas.sagan import SslResult 194 | 195 | source = "https://atlas.ripe.net/api/v1/measurement-latest/1012449/" 196 | response = requests.get(source).json 197 | 198 | for probe_id, result in response.items(): 199 | 200 | result = result[0] # There's only one result for each probe 201 | parsed_result = SslResult(result) # Parsing magic! 202 | 203 | # Each SslResult has n certificates 204 | for certificate in parsed_result.certificates: 205 | print(certificate.checksum) # Print the checksum for this certificate 206 | 207 | # Make use of the handy get_checksum_chain() to render the checksum of each certificate into one string if you want 208 | print(parsed_result.get_checksum_chain()) 209 | 210 | 211 | .. _#1012449: https://atlas.ripe.net/measurements/1012449/ 212 | 213 | 214 | .. _examples-types: 215 | 216 | Samples from Each Type 217 | ---------------------- 218 | 219 | 220 | .. _examples-types-ping: 221 | 222 | Ping 223 | .... 224 | 225 | For more information regarding all properties available, you should consult the 226 | :ref:`ping` section of this documentation.:: 227 | 228 | ping_result.packets_sent # Int 229 | ping_result.rtt_median # Float, rounded to 3 decimal places 230 | ping_result.rtt_average # Float, rounded to 3 decimal places 231 | 232 | 233 | .. _examples-types-traceroute: 234 | 235 | Traceroute 236 | .......... 237 | 238 | For more information regarding all properties available, you should consult the 239 | :ref:`traceroute` section of this documentation.:: 240 | 241 | traceroute_result.af # 4 or 6 242 | traceroute_result.total_hops # Int 243 | traceroute_result.destination_address # An IP address string 244 | 245 | 246 | .. _examples-types-dns: 247 | 248 | DNS 249 | .... 250 | 251 | For more information regarding all properties available, you should consult the 252 | :ref:`dns` section of this documentation.:: 253 | 254 | dns_result.responses # A list of Response objects 255 | dns_result.responses[0].response_time # Float, rounded to 3 decimal places 256 | dns_result.responses[0].headers # A list of Header objects 257 | dns_result.responses[0].headers[0].nscount # The NSCOUNT value for the first header 258 | dns_result.responses[0].questions # A list of Question objects 259 | dns_result.responses[0].questions[0].type # The TYPE value for the first question 260 | dns_result.responses[0].abuf # The raw, unparsed abuf string 261 | 262 | 263 | .. _examples-types-sslcert: 264 | 265 | SSL Certificates 266 | ................ 267 | 268 | For more information regarding all properties available, you should consult the 269 | :ref:`sslcert` section of this documentation.:: 270 | 271 | ssl_result.af # 4 or 6 272 | ssl_result.certificates # A list of Certificate objects 273 | ssl_result.certificates[0].checksum # The checksum for the first certificate 274 | 275 | 276 | .. _examples-types-http: 277 | 278 | HTTP 279 | .... 280 | 281 | For more information regarding all properties available, you should consult the 282 | :ref:`http` section of this documentation.:: 283 | 284 | http_result.af # 4 or 6 285 | http_result.uri # A URL string 286 | http_result.responses # A list of Response objects 287 | http_result.responses[0].body_size # The size of the body of the first response 288 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # RIPE Atlas Sagan documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Apr 29 13:41:57 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | __version__ = None 19 | exec(open("../ripe/atlas/sagan/version.py").read()) 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | #sys.path.insert(0, os.path.abspath('.')) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.viewcode', 37 | 'sphinx_rtd_theme', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = u'RIPE Atlas Sagan' 54 | copyright = u'2014, RIPE NCC' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | 60 | # 61 | # If the build process ever explodes here, it's because you've set the version 62 | # number in ripe.atlas.tools.version to a string in a format other than x.y.z 63 | # 64 | 65 | # The short X.Y version. 66 | version = ".".join(__version__.split(".")[:2]) 67 | # The full version, including alpha/beta/rc tags. 68 | release = ".".join(__version__.split(".")[:3]) 69 | 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | language = 'python' 74 | 75 | # There are two options for replacing |today|: either, you set today to some 76 | # non-false value, then it is used: 77 | #today = '' 78 | # Else, today_fmt is used as the format for a strftime call. 79 | #today_fmt = '%B %d, %Y' 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | exclude_patterns = ['_build'] 84 | 85 | # The reST default role (used for this markup: `text`) to use for all 86 | # documents. 87 | #default_role = None 88 | 89 | # If true, '()' will be appended to :func: etc. cross-reference text. 90 | #add_function_parentheses = True 91 | 92 | # If true, the current module name will be prepended to all description 93 | # unit titles (such as .. function::). 94 | #add_module_names = True 95 | 96 | # If true, sectionauthor and moduleauthor directives will be shown in the 97 | # output. They are ignored by default. 98 | #show_authors = False 99 | 100 | # The name of the Pygments (syntax highlighting) style to use. 101 | pygments_style = 'sphinx' 102 | 103 | # A list of ignored prefixes for module index sorting. 104 | #modindex_common_prefix = [] 105 | 106 | # If true, keep warnings as "system message" paragraphs in the built documents. 107 | #keep_warnings = False 108 | 109 | 110 | # -- Options for HTML output ---------------------------------------------- 111 | 112 | # The theme to use for HTML and HTML Help pages. See the documentation for 113 | # a list of builtin themes. 114 | html_theme = 'sphinx_rtd_theme' 115 | 116 | # Theme options are theme-specific and customize the look and feel of a theme 117 | # further. For a list of options available for each theme, see the 118 | # documentation. 119 | #html_theme_options = {} 120 | 121 | # Add any paths that contain custom themes here, relative to this directory. 122 | #html_theme_path = [] 123 | 124 | # The name for this set of Sphinx documents. If None, it defaults to 125 | # " v documentation". 126 | #html_title = None 127 | 128 | # A shorter title for the navigation bar. Default is the same as html_title. 129 | #html_short_title = None 130 | 131 | # The name of an image file (relative to this directory) to place at the top 132 | # of the sidebar. 133 | #html_logo = None 134 | 135 | # The name of an image file (within the static path) to use as favicon of the 136 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 137 | # pixels large. 138 | #html_favicon = None 139 | 140 | # Add any paths that contain custom static files (such as style sheets) here, 141 | # relative to this directory. They are copied after the builtin static files, 142 | # so a file named "default.css" will overwrite the builtin "default.css". 143 | html_static_path = ['_static'] 144 | 145 | # Add any extra paths that contain custom files (such as robots.txt or 146 | # .htaccess) here, relative to this directory. These files are copied 147 | # directly to the root of the documentation. 148 | #html_extra_path = [] 149 | 150 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 151 | # using the given strftime format. 152 | #html_last_updated_fmt = '%b %d, %Y' 153 | 154 | # If true, SmartyPants will be used to convert quotes and dashes to 155 | # typographically correct entities. 156 | #html_use_smartypants = True 157 | 158 | # Custom sidebar templates, maps document names to template names. 159 | #html_sidebars = {} 160 | 161 | # Additional templates that should be rendered to pages, maps page names to 162 | # template names. 163 | #html_additional_pages = {} 164 | 165 | # If false, no module index is generated. 166 | #html_domain_indices = True 167 | 168 | # If false, no index is generated. 169 | #html_use_index = True 170 | 171 | # If true, the index is split into individual pages for each letter. 172 | #html_split_index = False 173 | 174 | # If true, links to the reST sources are added to the pages. 175 | #html_show_sourcelink = True 176 | 177 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 178 | #html_show_sphinx = True 179 | 180 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 181 | #html_show_copyright = True 182 | 183 | # If true, an OpenSearch description file will be output, and all pages will 184 | # contain a tag referring to it. The value of this option must be the 185 | # base URL from which the finished HTML is served. 186 | #html_use_opensearch = '' 187 | 188 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 189 | #html_file_suffix = None 190 | 191 | # Output file base name for HTML help builder. 192 | htmlhelp_basename = 'RIPEAtlasSagandoc' 193 | 194 | 195 | # 196 | # Attempt to use the ReadTheDocs theme. If it's not installed, fallback to 197 | # the default. 198 | # 199 | 200 | try: 201 | import sphinx_rtd_theme 202 | html_theme = "sphinx_rtd_theme" 203 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 204 | except ImportError: 205 | pass 206 | 207 | 208 | # -- Options for LaTeX output --------------------------------------------- 209 | 210 | latex_elements = { 211 | # The paper size ('letterpaper' or 'a4paper'). 212 | #'papersize': 'letterpaper', 213 | 214 | # The font size ('10pt', '11pt' or '12pt'). 215 | #'pointsize': '10pt', 216 | 217 | # Additional stuff for the LaTeX preamble. 218 | #'preamble': '', 219 | } 220 | 221 | # Grouping the document tree into LaTeX files. List of tuples 222 | # (source start file, target name, title, 223 | # author, documentclass [howto, manual, or own class]). 224 | latex_documents = [ 225 | ('index', 'RIPEAtlasSagan.tex', u'RIPE Atlas Sagan Documentation', 226 | u'Daniel Quinn', 'manual'), 227 | ] 228 | 229 | # The name of an image file (relative to this directory) to place at the top of 230 | # the title page. 231 | #latex_logo = None 232 | 233 | # For "manual" documents, if this is true, then toplevel headings are parts, 234 | # not chapters. 235 | #latex_use_parts = False 236 | 237 | # If true, show page references after internal links. 238 | #latex_show_pagerefs = False 239 | 240 | # If true, show URL addresses after external links. 241 | #latex_show_urls = False 242 | 243 | # Documents to append as an appendix to all manuals. 244 | #latex_appendices = [] 245 | 246 | # If false, no module index is generated. 247 | #latex_domain_indices = True 248 | 249 | 250 | # -- Options for manual page output --------------------------------------- 251 | 252 | # One entry per manual page. List of tuples 253 | # (source start file, name, description, authors, manual section). 254 | man_pages = [ 255 | ('index', 'ripeatlassagan', u'RIPE Atlas Sagan Documentation', 256 | [u'Daniel Quinn'], 1) 257 | ] 258 | 259 | # If true, show URL addresses after external links. 260 | #man_show_urls = False 261 | 262 | 263 | # -- Options for Texinfo output ------------------------------------------- 264 | 265 | # Grouping the document tree into Texinfo files. List of tuples 266 | # (source start file, target name, title, author, 267 | # dir menu entry, description, category) 268 | texinfo_documents = [ 269 | ('index', 'RIPEAtlasSagan', u'RIPE Atlas Sagan Documentation', 270 | u'Daniel Quinn', 'RIPEAtlasSagan', 'One line description of project.', 271 | 'Miscellaneous'), 272 | ] 273 | 274 | # Documents to append as an appendix to all manuals. 275 | #texinfo_appendices = [] 276 | 277 | # If false, no module index is generated. 278 | #texinfo_domain_indices = True 279 | 280 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 281 | #texinfo_show_urls = 'footnote' 282 | 283 | # If true, do not generate a @detailmenu in the "Top" node's menu. 284 | #texinfo_no_detailmenu = False 285 | 286 | 287 | # -- Options for Epub output ---------------------------------------------- 288 | 289 | # Bibliographic Dublin Core info. 290 | epub_title = u'RIPE Atlas Sagan' 291 | epub_author = u'Daniel Quinn' 292 | epub_publisher = u'Daniel Quinn' 293 | epub_copyright = u'2014, Daniel Quinn' 294 | 295 | # The basename for the epub file. It defaults to the project name. 296 | #epub_basename = u'RIPE Atlas Sagan' 297 | 298 | # The HTML theme for the epub output. Since the default themes are not optimized 299 | # for small screen space, using the same theme for HTML and epub output is 300 | # usually not wise. This defaults to 'epub', a theme designed to save visual 301 | # space. 302 | #epub_theme = 'epub' 303 | 304 | # The language of the text. It defaults to the language option 305 | # or en if the language is not set. 306 | #epub_language = '' 307 | 308 | # The scheme of the identifier. Typical schemes are ISBN or URL. 309 | #epub_scheme = '' 310 | 311 | # The unique identifier of the text. This can be a ISBN number 312 | # or the project homepage. 313 | #epub_identifier = '' 314 | 315 | # A unique identification for the text. 316 | #epub_uid = '' 317 | 318 | # A tuple containing the cover image and cover page html template filenames. 319 | #epub_cover = () 320 | 321 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 322 | #epub_guide = () 323 | 324 | # HTML files that should be inserted before the pages created by sphinx. 325 | # The format is a list of tuples containing the path and title. 326 | #epub_pre_files = [] 327 | 328 | # HTML files shat should be inserted after the pages created by sphinx. 329 | # The format is a list of tuples containing the path and title. 330 | #epub_post_files = [] 331 | 332 | # A list of files that should not be packed into the epub file. 333 | epub_exclude_files = ['search.html'] 334 | 335 | # The depth of the table of contents in toc.ncx. 336 | #epub_tocdepth = 3 337 | 338 | # Allow duplicate toc entries. 339 | #epub_tocdup = True 340 | 341 | # Choose between 'default' and 'includehidden'. 342 | #epub_tocscope = 'default' 343 | 344 | # Fix unsupported image types using the PIL. 345 | #epub_fix_images = False 346 | 347 | # Scale large images. 348 | #epub_max_image_width = 0 349 | 350 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 351 | #epub_show_urls = 'inline' 352 | 353 | # If false, no index is generated. 354 | #epub_use_index = True 355 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from ripe.atlas.sagan import Result, ResultError, ResultParseError 17 | from ripe.atlas.sagan.http import HttpResult 18 | 19 | def test_http_0(): 20 | data = '{"fw":0,"msm_id":12023,"prb_id":1,"src_addr":"GET4 193.0.6.139 0.042268 200 263 1406","timestamp":1319704299,"type":"http"}' 21 | result = Result.get(data) 22 | assert(result.is_malformed is True) 23 | try: 24 | Result.get(data, on_malformation=Result.ACTION_FAIL) 25 | assert False 26 | except ResultParseError: 27 | pass 28 | 29 | def test_http_1_error(): 30 | data = '{"fw":1,"msm_id":12023,"prb_id":1,"src_addr":"connect error 4","timestamp":1323118908,"type":"http"}' 31 | result = Result.get(data) 32 | assert(result.is_malformed is True) 33 | try: 34 | Result.get(data, on_malformation=Result.ACTION_FAIL) 35 | assert False 36 | except ResultParseError: 37 | pass 38 | 39 | def test_http_1(): 40 | result = Result.get('{"from":"62.194.83.50","fw":1,"msm_id":12023,"prb_id":1,"result":"GET4 193.0.6.139 0.030229 200 263 1406","timestamp":1333387900,"type":"http"}') 41 | assert(isinstance(result, HttpResult)) 42 | assert(result.origin == "62.194.83.50") 43 | assert(result.firmware == 1) 44 | assert(result.measurement_id == 12023) 45 | assert(result.probe_id == 1) 46 | assert(result.created.isoformat() == "2012-04-02T17:31:40+00:00") 47 | assert(result.uri is None) 48 | assert(result.method == HttpResult.METHOD_GET) 49 | assert(isinstance(result.responses, list)) 50 | assert(len(result.responses) == 1) 51 | assert(result.responses[0].af is None) 52 | assert(result.responses[0].body_size == 1406) 53 | assert(result.responses[0].head_size == 263) 54 | assert(result.responses[0].destination_address == "193.0.6.139") 55 | assert(result.responses[0].code == 200) 56 | assert(result.responses[0].response_time == 30.229) 57 | assert(result.responses[0].source_address is None) 58 | assert(result.responses[0].version is None) 59 | 60 | def test_http_4430(): 61 | result = Result.get('{"from":"62.194.83.50","fw":4430,"msm_id":12023,"prb_id":1,"result":[{"addr":"193.0.6.139","bsize":1406,"hsize":263,"mode":"GET4","res":200,"rt":27.276,"srcaddr":"192.168.99.183","ver":"1.1"}],"timestamp":1336418202,"type":"http"}') 62 | assert(isinstance(result, HttpResult)) 63 | assert(result.origin == "62.194.83.50") 64 | assert(result.firmware == 4430) 65 | assert(result.measurement_id == 12023) 66 | assert(result.probe_id == 1) 67 | assert(result.created.isoformat() == "2012-05-07T19:16:42+00:00") 68 | assert(result.uri is None) 69 | assert(result.method == HttpResult.METHOD_GET) 70 | assert(isinstance(result.responses, list)) 71 | assert(len(result.responses) == 1) 72 | assert(result.responses[0].af is None) 73 | assert(result.responses[0].body_size == 1406) 74 | assert(result.responses[0].head_size == 263) 75 | assert(result.responses[0].destination_address == "193.0.6.139") 76 | assert(result.responses[0].code == 200) 77 | assert(result.responses[0].response_time == 27.276) 78 | assert(result.responses[0].source_address == "192.168.99.183") 79 | assert(result.responses[0].version == "1.1") 80 | 81 | def test_http_4460(): 82 | result = Result.get('{"from":"2001:780:100:6:220:4aff:fee0:2479","fw":4460,"msm_id":1003930,"prb_id":2707,"result":[{"af":6,"bsize":22383,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":71.146000000000001,"src_addr":"2001:780:100:6:220:4aff:fee0:2479","ver":"1.1"}],"timestamp":1350471605,"type":"http","uri":"http://www.ripe.net/"}') 83 | assert(isinstance(result, HttpResult)) 84 | assert(result.origin == "2001:780:100:6:220:4aff:fee0:2479") 85 | assert(result.firmware == 4460) 86 | assert(result.measurement_id == 1003930) 87 | assert(result.probe_id == 2707) 88 | assert(result.created.isoformat() == "2012-10-17T11:00:05+00:00") 89 | assert(result.uri == "http://www.ripe.net/") 90 | assert(result.method == HttpResult.METHOD_GET) 91 | assert(isinstance(result.responses, list)) 92 | assert(len(result.responses) == 1) 93 | assert(result.responses[0].af == 6) 94 | assert(result.responses[0].body_size == 22383) 95 | assert(result.responses[0].head_size == 279) 96 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 97 | assert(result.responses[0].code == 200) 98 | assert(result.responses[0].response_time == 71.146) 99 | assert(result.responses[0].source_address == result.origin) 100 | assert(result.responses[0].version == "1.1") 101 | 102 | 103 | def test_http_4470(): 104 | result = Result.get('{"from":"2001:4538:100:0:220:4aff:fec8:232b","fw":4470,"msm_id":1003930,"prb_id":303,"result":[{"af":6,"bsize":22243,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":765.25400000000002,"src_addr":"2001:4538:100:0:220:4aff:fec8:232b","ver":"1.1"}],"timestamp":1352869218,"type":"http","uri":"http://www.ripe.net/"}') 105 | assert(isinstance(result, HttpResult)) 106 | assert(result.origin == "2001:4538:100:0:220:4aff:fec8:232b") 107 | assert(result.firmware == 4470) 108 | assert(result.measurement_id == 1003930) 109 | assert(result.probe_id == 303) 110 | assert(result.created.isoformat() == "2012-11-14T05:00:18+00:00") 111 | assert(result.uri == "http://www.ripe.net/") 112 | assert(result.method == HttpResult.METHOD_GET) 113 | assert(isinstance(result.responses, list)) 114 | assert(len(result.responses) == 1) 115 | assert(result.responses[0].af == 6) 116 | assert(result.responses[0].body_size == 22243) 117 | assert(result.responses[0].head_size == 279) 118 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 119 | assert(result.responses[0].code == 200) 120 | assert(result.responses[0].response_time == 765.254) 121 | assert(result.responses[0].source_address == result.origin) 122 | assert(result.responses[0].version == "1.1") 123 | 124 | 125 | def test_http_4480(): 126 | result = Result.get('{"fw":4480,"msm_id":1003930,"prb_id":2184,"result":[{"af":6,"bsize":39777,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":660.40999999999997,"src_addr":"2a02:6c80:5:0:220:4aff:fee0:2774","ver":"1.1"}],"src_addr":"2a02:6c80:5:0:220:4aff:fee0:2774","timestamp":1372582858,"type":"http","uri":"http://www.ripe.net/"}') 127 | assert(isinstance(result, HttpResult)) 128 | assert(result.origin is None) 129 | assert(result.firmware == 4480) 130 | assert(result.measurement_id == 1003930) 131 | assert(result.probe_id == 2184) 132 | assert(result.created.isoformat() == "2013-06-30T09:00:58+00:00") 133 | assert(result.uri == "http://www.ripe.net/") 134 | assert(result.method == HttpResult.METHOD_GET) 135 | assert(isinstance(result.responses, list)) 136 | assert(len(result.responses) == 1) 137 | assert(result.responses[0].af == 6) 138 | assert(result.responses[0].body_size == 39777) 139 | assert(result.responses[0].head_size == 279) 140 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 141 | assert(result.responses[0].code == 200) 142 | assert(result.responses[0].response_time == 660.41) 143 | assert(result.responses[0].source_address == "2a02:6c80:5:0:220:4aff:fee0:2774") 144 | assert(result.responses[0].version == "1.1") 145 | 146 | 147 | def test_http_4500(): 148 | result = Result.get('{"from":"2a02:8304:1:4:220:4aff:fee0:228d","fw":4500,"msm_id":1003930,"prb_id":2954,"result":[{"af":6,"bsize":40103,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":234.048,"src_addr":"2a02:8304:1:4:220:4aff:fee0:228d","ver":"1.1"}],"timestamp":1367233244,"type":"http","uri":"http://www.ripe.net/"}') 149 | assert(isinstance(result, HttpResult)) 150 | assert(result.origin == "2a02:8304:1:4:220:4aff:fee0:228d") 151 | assert(result.firmware == 4500) 152 | assert(result.measurement_id == 1003930) 153 | assert(result.probe_id == 2954) 154 | assert(result.created.isoformat() == "2013-04-29T11:00:44+00:00") 155 | assert(result.uri == "http://www.ripe.net/") 156 | assert(result.method == HttpResult.METHOD_GET) 157 | assert(isinstance(result.responses, list)) 158 | assert(len(result.responses) == 1) 159 | assert(result.responses[0].af == 6) 160 | assert(result.responses[0].body_size == 40103) 161 | assert(result.responses[0].head_size == 279) 162 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 163 | assert(result.responses[0].code == 200) 164 | assert(result.responses[0].response_time == 234.048) 165 | assert(result.responses[0].source_address == result.origin) 166 | assert(result.responses[0].version == "1.1") 167 | 168 | 169 | def test_http_4520(): 170 | result = Result.get('{"from":"2a02:27d0:100:115:6000::250","fw":4520,"msm_id":1003930,"msm_name":"HTTPGet","prb_id":2802,"result":[{"af":6,"bsize":40567,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":102.825,"src_addr":"2a02:27d0:100:115:6000::250","ver":"1.1"}],"timestamp":1379594363,"type":"http","uri":"http://www.ripe.net/"}') 171 | assert(isinstance(result, HttpResult)) 172 | assert(result.origin == "2a02:27d0:100:115:6000::250") 173 | assert(result.firmware == 4520) 174 | assert(result.measurement_id == 1003930) 175 | assert(result.probe_id == 2802) 176 | assert(result.created.isoformat() == "2013-09-19T12:39:23+00:00") 177 | assert(result.uri == "http://www.ripe.net/") 178 | assert(result.method == HttpResult.METHOD_GET) 179 | assert(isinstance(result.responses, list)) 180 | assert(len(result.responses) == 1) 181 | assert(result.responses[0].af == 6) 182 | assert(result.responses[0].body_size == 40567) 183 | assert(result.responses[0].head_size == 279) 184 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 185 | assert(result.responses[0].code == 200) 186 | assert(result.responses[0].response_time == 102.825) 187 | assert(result.responses[0].source_address == result.origin) 188 | assert(result.responses[0].version == "1.1") 189 | 190 | 191 | def test_http_4540(): 192 | result = Result.get('{"from":"2001:980:36af:1:220:4aff:fec8:226d","fw":4540,"msm_id":1003930,"msm_name":"HTTPGet","prb_id":26,"result":[{"af":6,"bsize":40485,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":158.994,"src_addr":"2001:980:36af:1:220:4aff:fec8:226d","ver":"1.1"}],"timestamp":1377695803,"type":"http","uri":"http://www.ripe.net/"}') 193 | assert(isinstance(result, HttpResult)) 194 | assert(result.origin == "2001:980:36af:1:220:4aff:fec8:226d") 195 | assert(result.firmware == 4540) 196 | assert(result.measurement_id == 1003930) 197 | assert(result.probe_id == 26) 198 | assert(result.created.isoformat() == "2013-08-28T13:16:43+00:00") 199 | assert(result.uri == "http://www.ripe.net/") 200 | assert(result.method == HttpResult.METHOD_GET) 201 | assert(isinstance(result.responses, list)) 202 | assert(len(result.responses) == 1) 203 | assert(result.responses[0].af == 6) 204 | assert(result.responses[0].body_size == 40485) 205 | assert(result.responses[0].head_size == 279) 206 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 207 | assert(result.responses[0].code == 200) 208 | assert(result.responses[0].response_time == 158.994) 209 | assert(result.responses[0].source_address == "2001:980:36af:1:220:4aff:fec8:226d") 210 | assert(result.responses[0].version == "1.1") 211 | 212 | 213 | def test_http_4550(): 214 | result = Result.get('{"from":"2001:4538:100:0:220:4aff:fec8:232b","fw":4550,"msm_id":1003930,"msm_name":"HTTPGet","prb_id":303,"result":[{"af":6,"bsize":40118,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":2092.447,"src_addr":"2001:4538:100:0:220:4aff:fec8:232b","ver":"1.1"}],"timestamp":1380901810,"type":"http","uri":"http://www.ripe.net/"}') 215 | assert(isinstance(result, HttpResult)) 216 | assert(result.origin == "2001:4538:100:0:220:4aff:fec8:232b") 217 | assert(result.firmware == 4550) 218 | assert(result.measurement_id == 1003930) 219 | assert(result.probe_id == 303) 220 | assert(result.created.isoformat() == "2013-10-04T15:50:10+00:00") 221 | assert(result.uri == "http://www.ripe.net/") 222 | assert(result.method == HttpResult.METHOD_GET) 223 | assert(isinstance(result.responses, list)) 224 | assert(len(result.responses) == 1) 225 | assert(result.responses[0].af == 6) 226 | assert(result.responses[0].body_size == 40118) 227 | assert(result.responses[0].head_size == 279) 228 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 229 | assert(result.responses[0].code == 200) 230 | assert(result.responses[0].response_time == 2092.447) 231 | assert(result.responses[0].source_address == "2001:4538:100:0:220:4aff:fec8:232b") 232 | assert(result.responses[0].version == "1.1") 233 | 234 | 235 | def test_http_4560(): 236 | result = Result.get('{"from":"2620:0:2ed0:aaaa::210","fw":4560,"msm_id":1003930,"msm_name":"HTTPGet","prb_id":1164,"result":[{"af":6,"bsize":39340,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":739.26999999999998,"src_addr":"2620:0:2ed0:aaaa::210","ver":"1.1"}],"timestamp":1385895661,"type":"http","uri":"http://www.ripe.net/"}') 237 | assert(isinstance(result, HttpResult)) 238 | assert(result.origin == "2620:0:2ed0:aaaa::210") 239 | assert(result.firmware == 4560) 240 | assert(result.measurement_id == 1003930) 241 | assert(result.probe_id == 1164) 242 | assert(result.created.isoformat() == "2013-12-01T11:01:01+00:00") 243 | assert(result.uri == "http://www.ripe.net/") 244 | assert(result.method == HttpResult.METHOD_GET) 245 | assert(isinstance(result.responses, list)) 246 | assert(len(result.responses) == 1) 247 | assert(result.responses[0].af == 6) 248 | assert(result.responses[0].body_size == 39340) 249 | assert(result.responses[0].head_size == 279) 250 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 251 | assert(result.responses[0].code == 200) 252 | assert(result.responses[0].response_time == 739.27) 253 | assert(result.responses[0].source_address == "2620:0:2ed0:aaaa::210") 254 | assert(result.responses[0].version == "1.1") 255 | 256 | 257 | def test_http_4570(): 258 | result = Result.get('{"from":"2001:8b0:34f:5b8:220:4aff:fee0:21fc","fw":4570,"msm_id":1003930,"msm_name":"HTTPGet","prb_id":2030,"result":[{"af":6,"bsize":41021,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":187.02199999999999,"src_addr":"2001:8b0:34f:5b8:220:4aff:fee0:21fc","ver":"1.1"}],"timestamp":1395324614,"type":"http","uri":"http://www.ripe.net/"}') 259 | assert(isinstance(result, HttpResult)) 260 | assert(result.origin == "2001:8b0:34f:5b8:220:4aff:fee0:21fc") 261 | assert(result.firmware == 4570) 262 | assert(result.measurement_id == 1003930) 263 | assert(result.probe_id == 2030) 264 | assert(result.created.isoformat() == "2014-03-20T14:10:14+00:00") 265 | assert(result.uri == "http://www.ripe.net/") 266 | assert(result.method == HttpResult.METHOD_GET) 267 | assert(isinstance(result.responses, list)) 268 | assert(len(result.responses) == 1) 269 | assert(result.responses[0].af == 6) 270 | assert(result.responses[0].body_size == 41021) 271 | assert(result.responses[0].head_size == 279) 272 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 273 | assert(result.responses[0].code == 200) 274 | assert(result.responses[0].response_time == 187.022) 275 | assert(result.responses[0].source_address == "2001:8b0:34f:5b8:220:4aff:fee0:21fc") 276 | assert(result.responses[0].version == "1.1") 277 | 278 | 279 | def test_http_4600(): 280 | result = Result.get('{"from":"2a01:6a8:0:f:220:4aff:fec5:5b5a","fw":4600,"msm_id":1003930,"msm_name":"HTTPGet","prb_id":720,"result":[{"af":6,"bsize":41020,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":195.84399999999999,"src_addr":"2a01:6a8:0:f:220:4aff:fec5:5b5a","ver":"1.1"}],"timestamp":1396163194,"type":"http","uri":"http://www.ripe.net/"}') 281 | assert(isinstance(result, HttpResult)) 282 | assert(result.origin == "2a01:6a8:0:f:220:4aff:fec5:5b5a") 283 | assert(result.firmware == 4600) 284 | assert(result.measurement_id == 1003930) 285 | assert(result.probe_id == 720) 286 | assert(result.created.isoformat() == "2014-03-30T07:06:34+00:00") 287 | assert(result.uri == "http://www.ripe.net/") 288 | assert(result.method == HttpResult.METHOD_GET) 289 | assert(isinstance(result.responses, list)) 290 | assert(len(result.responses) == 1) 291 | assert(result.responses[0].af == 6) 292 | assert(result.responses[0].body_size == 41020) 293 | assert(result.responses[0].head_size == 279) 294 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 295 | assert(result.responses[0].code == 200) 296 | assert(result.responses[0].response_time == 195.844) 297 | assert(result.responses[0].source_address == "2a01:6a8:0:f:220:4aff:fec5:5b5a") 298 | assert(result.responses[0].version == "1.1") 299 | 300 | 301 | def test_http_4610(): 302 | result = Result.get('{"from":"2a01:9e00:a217:d00:220:4aff:fec6:cb5b","fw":4610,"group_id":1003930,"msm_id":1003930,"msm_name":"HTTPGet","prb_id":780,"result":[{"af":6,"bsize":41020,"dst_addr":"2001:67c:2e8:22::c100:68b","hsize":279,"method":"GET","res":200,"rt":154.755,"src_addr":"2a01:9e00:a217:d00:220:4aff:fec6:cb5b","ver":"1.1"}],"timestamp":1396359320,"type":"http","uri":"http://www.ripe.net/"}') 303 | assert(isinstance(result, HttpResult)) 304 | assert(result.origin == "2a01:9e00:a217:d00:220:4aff:fec6:cb5b") 305 | assert(result.firmware == 4610) 306 | assert(result.measurement_id == 1003930) 307 | assert(result.probe_id == 780) 308 | assert(result.created.isoformat() == "2014-04-01T13:35:20+00:00") 309 | assert(result.uri == "http://www.ripe.net/") 310 | assert(result.method == HttpResult.METHOD_GET) 311 | assert(isinstance(result.responses, list)) 312 | assert(len(result.responses) == 1) 313 | assert(result.responses[0].af == 6) 314 | assert(result.responses[0].body_size == 41020) 315 | assert(result.responses[0].head_size == 279) 316 | assert(result.responses[0].destination_address == "2001:67c:2e8:22::c100:68b") 317 | assert(result.responses[0].code == 200) 318 | assert(result.responses[0].response_time == 154.755) 319 | assert(result.responses[0].source_address == "2a01:9e00:a217:d00:220:4aff:fec6:cb5b") 320 | assert(result.responses[0].version == "1.1") 321 | 322 | 323 | def test_http_4610_fail(): 324 | result = Result.get('{"from":"2001:630:301:1080:220:4aff:fee0:20a0","fw":4610,"msm_id":1003932,"msm_name":"HTTPGet","prb_id":2493,"result":[{"af":6,"dst_addr":"2001:42d0:0:200::6","err":"timeout reading chunk","method":"GET","src_addr":"2001:630:301:1080:220:4aff:fee0:20a0"}],"timestamp":1398184661,"type":"http","uri":"http://www.afrinic.net/"}') 325 | assert(isinstance(result, HttpResult)) 326 | assert(result.origin == "2001:630:301:1080:220:4aff:fee0:20a0") 327 | assert(result.firmware == 4610) 328 | assert(result.measurement_id == 1003932) 329 | assert(result.probe_id == 2493) 330 | assert(result.created.isoformat() == "2014-04-22T16:37:41+00:00") 331 | assert(result.uri == "http://www.afrinic.net/") 332 | assert(result.method == HttpResult.METHOD_GET) 333 | assert(isinstance(result.responses, list)) 334 | assert(len(result.responses) == 1) 335 | assert(result.responses[0].af == 6) 336 | assert(result.responses[0].body_size is None) 337 | assert(result.responses[0].head_size is None) 338 | assert(result.responses[0].destination_address == "2001:42d0:0:200::6") 339 | assert(result.responses[0].code is None) 340 | assert(result.responses[0].response_time is None) 341 | assert(result.responses[0].source_address == "2001:630:301:1080:220:4aff:fee0:20a0") 342 | assert(result.responses[0].version is None) 343 | assert(result.responses[0].is_error is True) 344 | assert(result.responses[0].error_message == "timeout reading chunk") 345 | 346 | def test_http_lts(): 347 | result = Result.get('{"lts":275,"from":"","msm_id":1003932,"fw":4650,"timestamp":1406558081,"uri":"http:\/\/www.afrinic.net\/","prb_id":902,"result":[{"method":"GET","dst_addr":"2001:42d0:0:200::6","err":"connect: Network is unreachable","af":6}],"type":"http","msm_name":"HTTPGet"}') 348 | assert(result.seconds_since_sync == 275) 349 | -------------------------------------------------------------------------------- /ripe/atlas/sagan/dns.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from __future__ import absolute_import 17 | 18 | import base64 19 | from collections import namedtuple 20 | from datetime import datetime 21 | from pytz import UTC 22 | 23 | from .base import Result, ParsingDict 24 | from .helpers import abuf 25 | from .helpers import compatibility 26 | 27 | 28 | class Header(ParsingDict): 29 | 30 | def __init__(self, data, **kwargs): 31 | 32 | ParsingDict.__init__(self, **kwargs) 33 | 34 | self.raw_data = data 35 | self.aa = self.ensure("AA", bool) 36 | self.qr = self.ensure("QR", bool) 37 | self.nscount = self.ensure("NSCOUNT", int) 38 | self.qdcount = self.ensure("QDCOUNT", int) 39 | self.ancount = self.ensure("ANCOUNT", int) 40 | self.tc = self.ensure("TC", bool) 41 | self.rd = self.ensure("RD", bool) 42 | self.arcount = self.ensure("ARCOUNT", int) 43 | self.return_code = self.ensure("ReturnCode", str) 44 | self.opcode = self.ensure("OpCode", str) 45 | self.ra = self.ensure("RA", bool) 46 | self.z = self.ensure("Z", int) 47 | self.ad = self.ensure("AD", bool) 48 | self.cd = self.ensure("CD", bool) 49 | self.id = self.ensure("ID", int) 50 | 51 | def __str__(self): 52 | return "Header: " + self.return_code 53 | 54 | @property 55 | def flags(self): 56 | flags = namedtuple( 57 | "Flags", ("qr", "aa", "tc", "rd", "ra", "z", "ad", "cd")) 58 | return flags(qr=self.qr, aa=self.aa, tc=self.tc, rd=self.rd, 59 | ra=self.ra, z=self.z, ad=self.ad, cd=self.cd) 60 | 61 | @property 62 | def sections(self): 63 | sections = namedtuple( 64 | "Sections", ("QDCOUNT", "ANCOUNT", "NSCOUNT", "ARCOUNT")) 65 | return sections(QDCOUNT=self.qdcount, ANCOUNT=self.ancount, 66 | NSCOUNT=self.nscount, ARCOUNT=self.arcount) 67 | 68 | @property 69 | def is_authoritative(self): 70 | return self.aa 71 | 72 | @property 73 | def is_query(self): 74 | if self.qr is None: 75 | return None 76 | return not self.qr 77 | 78 | @property 79 | def nameserver_count(self): 80 | """ 81 | Otherwise known as the NSCOUNT or the authority_count. 82 | """ 83 | return self.nscount 84 | 85 | @property 86 | def question_count(self): 87 | return self.qdcount 88 | 89 | @property 90 | def answer_count(self): 91 | return self.ancount 92 | 93 | @property 94 | def is_truncated(self): 95 | return self.tc 96 | 97 | @property 98 | def recursion_desired(self): 99 | return self.rd 100 | 101 | @property 102 | def additional_count(self): 103 | return self.arcount 104 | 105 | @property 106 | def recursion_available(self): 107 | return self.ra 108 | 109 | @property 110 | def zero(self): 111 | return self.z 112 | 113 | @property 114 | def checking_disabled(self): 115 | return self.cd 116 | 117 | @property 118 | def authenticated_data(self): 119 | return self.aa 120 | 121 | 122 | class Option(ParsingDict): 123 | 124 | def __init__(self, data, **kwargs): 125 | 126 | ParsingDict.__init__(self, **kwargs) 127 | 128 | self.raw_data = data 129 | self.nsid = self.ensure("NSID", str) 130 | self.code = self.ensure("OptionCode", int) 131 | self.length = self.ensure("OptionLength", int) 132 | self.name = self.ensure("OptionName", str) 133 | 134 | 135 | class Edns0(ParsingDict): 136 | 137 | def __init__(self, data, **kwargs): 138 | 139 | ParsingDict.__init__(self, **kwargs) 140 | 141 | self.raw_data = data 142 | self.extended_return_code = self.ensure("ExtendedReturnCode", int) 143 | self.name = self.ensure("Name", str) 144 | self.type = self.ensure("Type", str) 145 | self.udp_size = self.ensure("UDPsize", int) 146 | self.version = self.ensure("Version", int) 147 | self.z = self.ensure("Z", int) 148 | self.do = bool(self.ensure("DO", bool)) 149 | 150 | self.options = [] 151 | if "Option" in self.raw_data: 152 | if isinstance(self.raw_data["Option"], list): 153 | for option in self.raw_data["Option"]: 154 | self.options.append(Option(option)) 155 | 156 | 157 | class Question(ParsingDict): 158 | 159 | def __init__(self, data, **kwargs): 160 | 161 | ParsingDict.__init__(self, **kwargs) 162 | 163 | self.raw_data = data 164 | self.klass = self.ensure("Qclass", str) 165 | self.type = self.ensure("Qtype", str) 166 | self.name = self.ensure("Qname", str) 167 | 168 | def __str__(self): 169 | return ";{:30} {:<5} {:5}".format(self.name, self.klass, self.type) 170 | 171 | 172 | class Answer(ParsingDict): 173 | 174 | def __init__(self, data, **kwargs): 175 | 176 | ParsingDict.__init__(self, **kwargs) 177 | 178 | self.raw_data = data 179 | self.name = self.ensure("Name", str) 180 | self.ttl = self.ensure("TTL", int) 181 | self.type = self.ensure("Type", str) 182 | self.klass = self.ensure("Class", str) 183 | self.rd_length = self.ensure("RDlength", int) 184 | 185 | # Where data goes when the abuf parser can't understand things 186 | self.rdata = self.ensure("Rdata", str) 187 | 188 | @property 189 | def resource_data_length(self): 190 | return self.rd_length 191 | 192 | def __str__(self): 193 | return "{:22} {:<7} {:5} {:5}".format( 194 | self.name, 195 | self.ttl, 196 | self.klass, 197 | self.type 198 | ) 199 | 200 | 201 | class AAnswer(Answer): 202 | 203 | def __init__(self, data, **kwargs): 204 | Answer.__init__(self, data, **kwargs) 205 | self.address = self.ensure("Address", str) 206 | 207 | def __str__(self): 208 | return "{0} {1}".format(Answer.__str__(self), self.address) 209 | 210 | 211 | class AaaaAnswer(AAnswer): 212 | pass 213 | 214 | 215 | class NsAnswer(Answer): 216 | 217 | def __init__(self, data, **kwargs): 218 | Answer.__init__(self, data, **kwargs) 219 | self.target = self.ensure("Target", str) 220 | 221 | def __str__(self): 222 | return "{0} {1}".format(Answer.__str__(self), self.target) 223 | 224 | 225 | class CnameAnswer(NsAnswer): 226 | pass 227 | 228 | 229 | class MxAnswer(Answer): 230 | 231 | def __init__(self, data, **kwargs): 232 | Answer.__init__(self, data, **kwargs) 233 | self.preference = self.ensure("Preference", int) 234 | self.mail_exchanger = self.ensure("MailExchanger", str) 235 | 236 | def __str__(self): 237 | return "{0} {1} {2}".format( 238 | Answer.__str__(self), 239 | self.preference, 240 | self.mail_exchanger 241 | ) 242 | 243 | 244 | class SoaAnswer(Answer): 245 | 246 | def __init__(self, data, **kwargs): 247 | Answer.__init__(self, data, **kwargs) 248 | self.mname = self.ensure("MasterServerName", str) 249 | self.rname = self.ensure("MaintainerName", str) 250 | self.serial = self.ensure("Serial", int) 251 | self.refresh = self.ensure("Refresh", int) 252 | self.retry = self.ensure("Retry", int) 253 | self.expire = self.ensure("Expire", int) 254 | self.minimum = self.ensure("NegativeTtl", int) 255 | 256 | def __str__(self): 257 | return "{0} {1} {2} {3} {4} {5} {6} {7}".format( 258 | Answer.__str__(self), 259 | self.mname, 260 | self.rname, 261 | self.serial, 262 | self.refresh, 263 | self.retry, 264 | self.expire, 265 | self.minimum 266 | ) 267 | 268 | @property 269 | def master_server_name(self): 270 | return self.mname 271 | 272 | @property 273 | def maintainer_name(self): 274 | return self.rname 275 | 276 | @property 277 | def negative_ttl(self): 278 | return self.minimum 279 | 280 | @property 281 | def nxdomain(self): 282 | return self.minimum 283 | 284 | 285 | class DsAnswer(Answer): 286 | 287 | def __init__(self, data, **kwargs): 288 | Answer.__init__(self, data, **kwargs) 289 | self.tag = self.ensure("Tag", int) 290 | self.algorithm = self.ensure("Algorithm", int) 291 | self.digest_type = self.ensure("DigestType", int) 292 | self.delegation_key = self.ensure("DelegationKey", str) 293 | 294 | def __str__(self): 295 | return "{0} {1} {2} {3} {4}".format( 296 | Answer.__str__(self), 297 | self.tag, 298 | self.algorithm, 299 | self.digest_type, 300 | self.delegation_key 301 | ) 302 | 303 | 304 | class DnskeyAnswer(Answer): 305 | 306 | def __init__(self, data, **kwargs): 307 | Answer.__init__(self, data, **kwargs) 308 | self.flags = self.ensure("Flags", int) 309 | self.algorithm = self.ensure("Algorithm", int) 310 | self.protocol = self.ensure("Protocol", int) 311 | self.key = self.ensure("Key", str) 312 | 313 | def __str__(self): 314 | return "{0} {1} {2} {3} {4}".format( 315 | Answer.__str__(self), 316 | self.flags, 317 | self.algorithm, 318 | self.protocol, 319 | self.key 320 | ) 321 | 322 | 323 | class TxtAnswer(Answer): 324 | 325 | def __init__(self, data, **kwargs): 326 | 327 | Answer.__init__(self, data, **kwargs) 328 | 329 | self.data = [] 330 | if "Data" in self.raw_data: 331 | if isinstance(self.raw_data["Data"], list): 332 | self.data = [] 333 | for s in self.raw_data["Data"]: 334 | if isinstance(s, compatibility.string): 335 | self.data.append(s) 336 | 337 | def __str__(self): 338 | return "{0} {1}".format(Answer.__str__(self), self.data_string) 339 | 340 | @property 341 | def data_string(self): 342 | return " ".join(self.data) 343 | 344 | 345 | class RRSigAnswer(Answer): 346 | 347 | def __init__(self, data, **kwargs): 348 | Answer.__init__(self, data, **kwargs) 349 | self.type_covered = self.ensure("TypeCovered", str) 350 | self.algorithm = self.ensure("Algorithm", int) 351 | self.labels = self.ensure("Labels", int) 352 | self.original_ttl = self.ensure("OriginalTTL", int) 353 | self.signature_expiration = self.ensure("SignatureExpiration", int) 354 | self.signature_inception = self.ensure("SignatureInception", int) 355 | self.key_tag = self.ensure("KeyTag", int) 356 | self.signer_name = self.ensure("SignerName", str) 357 | self.signature = self.ensure("Signature", str) 358 | 359 | def __str__(self): 360 | 361 | formatter = "%Y%m%d%H%M%S" 362 | 363 | expiration = datetime.fromtimestamp( 364 | self.signature_expiration, tz=UTC).strftime(formatter) 365 | 366 | inception = datetime.fromtimestamp( 367 | self.signature_inception, tz=UTC).strftime(formatter) 368 | 369 | return "{0} {1} {2} {3} {4} {5} {6} {7} {8} {9}".format( 370 | Answer.__str__(self), 371 | self.type_covered, 372 | self.algorithm, 373 | self.labels, 374 | self.original_ttl, 375 | expiration, 376 | inception, 377 | self.key_tag, 378 | self.signer_name, 379 | self.signature 380 | ) 381 | 382 | 383 | class NotFullySupportedAnswer(Answer): 384 | """ 385 | We're still working on getting the proper text representations of some 386 | Answer classes, so such classes will inherit from this one. 387 | """ 388 | 389 | def __str__(self): 390 | return "{0} ---- Not fully supported ----".format(Answer.__str__(self)) 391 | 392 | 393 | class NsecAnswer(NotFullySupportedAnswer): 394 | 395 | def __init__(self, data, **kwargs): 396 | Answer.__init__(self, data, **kwargs) 397 | self.next_domain_name = self.ensure("NextDomainName", str) 398 | self.types = self.ensure("Types", list) 399 | 400 | 401 | class Nsec3Answer(NotFullySupportedAnswer): 402 | 403 | def __init__(self, data, **kwargs): 404 | Answer.__init__(self, data, **kwargs) 405 | self.hash_algorithm = self.ensure("HashAlg", int) 406 | self.flags = self.ensure("Flags", int) 407 | self.iterations = self.ensure("Iterations", int) 408 | self.salt = self.ensure("Salt", str) 409 | self.hash = self.ensure("Hash", str) 410 | self.types = self.ensure("Types", list) 411 | 412 | 413 | class Nsec3ParamAnswer(NotFullySupportedAnswer): 414 | 415 | def __init__(self, data, **kwargs): 416 | Answer.__init__(self, data, **kwargs) 417 | self.algorithm = self.ensure("Algorithm", int) 418 | self.flags = self.ensure("Flags", int) 419 | self.iterations = self.ensure("Iterations", int) 420 | self.salt = self.ensure("Salt", str) 421 | 422 | 423 | class PtrAnswer(NotFullySupportedAnswer): 424 | 425 | def __init__(self, data, **kwargs): 426 | Answer.__init__(self, data, **kwargs) 427 | self.target = self.ensure("Target", str) 428 | 429 | 430 | class SrvAnswer(NotFullySupportedAnswer): 431 | 432 | def __init__(self, data, **kwargs): 433 | Answer.__init__(self, data, **kwargs) 434 | self.priority = self.ensure("Priority", int) 435 | self.weight = self.ensure("Weight", int) 436 | self.port = self.ensure("Port", int) 437 | self.target = self.ensure("Target", str) 438 | 439 | 440 | class SshfpAnswer(NotFullySupportedAnswer): 441 | 442 | def __init__(self, data, **kwargs): 443 | Answer.__init__(self, data, **kwargs) 444 | self.algorithm = self.ensure("Algorithm", int) 445 | self.digest_type = self.ensure("DigestType", int) 446 | self.fingerprint = self.ensure("Fingerprint", str) 447 | 448 | 449 | class TlsaAnswer(NotFullySupportedAnswer): 450 | 451 | def __init__(self, data, **kwargs): 452 | Answer.__init__(self, data, **kwargs) 453 | self.certificate_usage = self.ensure("CertUsage", int) 454 | self.selector = self.ensure("Selector", int) 455 | self.matching_type = self.ensure("MatchingType", int) 456 | self.certificate_associated_data = self.ensure("CertAssData", str) 457 | 458 | 459 | class HinfoAnswer(NotFullySupportedAnswer): 460 | 461 | def __init__(self, data, **kwargs): 462 | Answer.__init__(self, data, **kwargs) 463 | self.cpu = self.ensure("Cpu", str) 464 | self.os = self.ensure("Os", str) 465 | 466 | 467 | class Message(ParsingDict): 468 | 469 | ANSWER_CLASSES = { 470 | "A": AAnswer, 471 | "AAAA": AaaaAnswer, 472 | "NS": NsAnswer, 473 | "CNAME": CnameAnswer, 474 | "MX": MxAnswer, 475 | "SOA": SoaAnswer, 476 | "DS": DsAnswer, 477 | "DNSKEY": DnskeyAnswer, 478 | "TXT": TxtAnswer, 479 | "RRSIG": RRSigAnswer, 480 | "NSEC": NsecAnswer, 481 | "NSEC3": Nsec3Answer, 482 | "NSEC3PARAM": Nsec3ParamAnswer, 483 | "PTR": PtrAnswer, 484 | "SRV": SrvAnswer, 485 | "SSHFP": SshfpAnswer, 486 | "TLSA": TlsaAnswer, 487 | "HINFO": HinfoAnswer 488 | } 489 | 490 | def __init__(self, message, response_data, parse_buf=True, **kwargs): 491 | 492 | ParsingDict.__init__(self, **kwargs) 493 | 494 | self._string_representation = message 495 | self.raw_data = {} 496 | 497 | if parse_buf: 498 | self._parse_buf(message) 499 | else: 500 | self._backfill_raw_data_from_result(response_data) 501 | 502 | self.header = None 503 | if "HEADER" in self.raw_data: 504 | self.header = Header(self.raw_data["HEADER"], **kwargs) 505 | 506 | # This is a tricky one, since you can't know that the response is an 507 | # error until *after* the abuf is parsed, and it won't be parsed 508 | # until you attempt to access it. 509 | code = self.header.return_code 510 | if not code or code.upper() != "NOERROR": 511 | self._handle_error('The response did not contain "NOERROR"') 512 | 513 | self.edns0 = None 514 | self.questions = [] 515 | self.answers = [] 516 | self.authorities = [] 517 | self.additionals = [] 518 | 519 | if "EDNS0" in self.raw_data: 520 | self.edns0 = Edns0(self.raw_data["EDNS0"], **kwargs) 521 | 522 | for question in self.raw_data.get("QuestionSection", []): 523 | self.questions.append(Question(question, **kwargs)) 524 | 525 | for answer in self.raw_data.get("AnswerSection", []): 526 | self._append_answer(answer, "answers", **kwargs) 527 | for authority in self.raw_data.get("AuthoritySection", []): 528 | self._append_answer(authority, "authorities", **kwargs) 529 | for additional in self.raw_data.get("AdditionalSection", []): 530 | self._append_answer(additional, "additionals", **kwargs) 531 | 532 | def __str__(self): 533 | return self._string_representation 534 | 535 | def __repr__(self): 536 | return str(self) 537 | 538 | def _append_answer(self, answer, section, **kwargs): 539 | answer_type = answer.get("Type") 540 | if answer_type is None: 541 | self._handle_malformation( 542 | "Answer has no parseable Type: {answer}".format( 543 | answer=answer 544 | ) 545 | ) 546 | answer_class = self.ANSWER_CLASSES.get(answer_type, Answer) 547 | getattr(self, section).append(answer_class(answer, **kwargs)) 548 | 549 | def _parse_buf(self, message): 550 | 551 | try: 552 | self.raw_data = abuf.AbufParser.parse(base64.b64decode(message)) 553 | except Exception as e: 554 | self.raw_data = {} 555 | self._handle_malformation( 556 | "{exception}: Unable to parse buffer: {buffer}".format( 557 | exception=e, 558 | buffer=self._string_representation 559 | ) 560 | ) 561 | else: 562 | if "ERROR" in self.raw_data: 563 | self._handle_error(self.raw_data["ERROR"]) 564 | 565 | def _backfill_raw_data_from_result(self, response_data): 566 | 567 | # Header 568 | self.raw_data["Header"] = {} 569 | for key in ("NSCOUNT", "QDCOUNT", "ID", "ARCOUNT", "ANCOUNT"): 570 | if key in response_data: 571 | self.raw_data["Header"][key] = response_data[key] 572 | 573 | # Answers 574 | if "answers" in response_data and response_data["answers"]: 575 | 576 | # The names used in the result don't align to those used in the abuf 577 | # parser 578 | name_map = { 579 | "TTL": "TTL", 580 | "TYPE": "Type", 581 | "NAME": "Name", 582 | "RDATA": "Data", 583 | "MNAME": "MasterServerName", 584 | "RNAME": "MaintainerName", 585 | "SERIAL": "Serial", 586 | "RDLENGTH": "RDlength", 587 | } 588 | 589 | self.raw_data["AnswerSection"] = [] 590 | for answer in response_data["answers"]: 591 | 592 | temporary = {} 593 | 594 | for k, v in name_map.items(): 595 | if k in answer: 596 | temporary[v] = answer[k] 597 | 598 | # Special case where some older txt entires are strings and not 599 | # a list 600 | if temporary.get("Type") == "TXT": 601 | if isinstance(temporary.get("Data"), compatibility.string): 602 | temporary["Data"] = [temporary["Data"]] 603 | 604 | if temporary: 605 | self.raw_data["AnswerSection"].append(temporary) 606 | 607 | 608 | class Response(ParsingDict): 609 | 610 | def __init__(self, data, af=None, destination=None, source=None, 611 | protocol=None, part_of_set=True, parse_buf=True, **kwargs): 612 | 613 | ParsingDict.__init__(self, **kwargs) 614 | 615 | self.raw_data = data 616 | 617 | self.af = self.ensure("af", int, af) 618 | self.destination_address = self.ensure("dst_addr", str, destination) 619 | self.source_address = self.ensure("src_addr", str, source) 620 | self.protocol = self.ensure("proto", str, protocol) 621 | 622 | self.response_id = None 623 | 624 | # Preparing for lazy stuff 625 | self._abuf = None 626 | self._qbuf = None 627 | self._parse_buf = parse_buf 628 | 629 | try: 630 | self.response_time = round(float(self.raw_data["result"]["rt"]), 3) 631 | except KeyError: 632 | try: 633 | self.response_time = round(self.ensure("rt", float), 3) 634 | except TypeError: 635 | self.response_time = None 636 | 637 | try: 638 | self.response_size = self.raw_data["result"]["size"] 639 | except KeyError: 640 | self.response_size = self.ensure("size", int) 641 | 642 | if part_of_set: 643 | self.response_id = self.ensure("subid", int) 644 | 645 | if self.protocol and isinstance(self.protocol, str): 646 | self.protocol = self.clean_protocol(self.protocol) 647 | 648 | @property 649 | def abuf(self): 650 | return self._get_buf("a") 651 | 652 | @property 653 | def qbuf(self): 654 | return self._get_buf("q") 655 | 656 | def _get_buf(self, prefix): 657 | """ 658 | Lazy read-only accessor for the (a|q)buf. 659 | The qbuf Message object is cached for subsequent requests. 660 | """ 661 | 662 | kind = "{prefix}buf".format(prefix=prefix) 663 | private_name = "_" + kind 664 | buf = getattr(self, private_name) 665 | 666 | if buf: 667 | return buf 668 | 669 | try: 670 | buf_string = self.raw_data["result"][kind] 671 | except KeyError: 672 | buf_string = self.ensure(kind, str) 673 | if buf_string: 674 | message = Message( 675 | buf_string, 676 | self.raw_data, 677 | parse_buf=self._parse_buf, 678 | on_error=self._on_error, 679 | on_malformation=self._on_malformation 680 | ) 681 | if message.is_error: 682 | self._handle_error(message.error_message) 683 | setattr(self, private_name, message) 684 | return getattr(self, private_name) 685 | 686 | 687 | class DnsResult(Result): 688 | 689 | def __init__(self, data, parse_buf=True, **kwargs): 690 | """ 691 | Note that we're not setting `self.af` here, but rather we have it as a 692 | property of `Response` as it's possible that one result can contain 693 | multiple responses, each with either af=4 or af=6. 694 | """ 695 | 696 | Result.__init__(self, data, **kwargs) 697 | 698 | self.responses = [] 699 | self.responses_total = None 700 | 701 | af = self.ensure("af", int) 702 | protocol = self.ensure("proto", str) 703 | source_address = self.ensure("src_addr", str) 704 | destination_address = self.ensure("dst_addr", str) 705 | 706 | if 0 < self.firmware < 4460: 707 | af = self.ensure("pf", int) 708 | 709 | part_of_set, responses = self.build_responses() 710 | 711 | for response in responses: 712 | self.responses.append(Response( 713 | response, 714 | af=af, 715 | destination=destination_address, 716 | source=source_address, 717 | protocol=protocol, 718 | part_of_set=part_of_set, 719 | parse_buf=parse_buf, 720 | **kwargs 721 | )) 722 | 723 | if "error" in self.raw_data: 724 | if isinstance(self.raw_data["error"], dict): 725 | if "timeout" in self.raw_data["error"]: 726 | self._handle_error("Timeout: {timeout}".format( 727 | timeout=self.raw_data["error"]["timeout"] 728 | )) 729 | elif "getaddrinfo" in self.raw_data["error"]: 730 | self._handle_error("Name resolution error: {msg}".format( 731 | msg=self.raw_data["error"]["getaddrinfo"] 732 | )) 733 | else: 734 | self._handle_error("Unknown error: {msg}".format( 735 | msg=self.raw_data["error"] 736 | )) 737 | else: 738 | self._handle_error("Unknown error: {msg}".format( 739 | msg=self.raw_data["error"] 740 | )) 741 | 742 | def build_responses(self): 743 | """ 744 | DNS measurement results are a little wacky. Sometimes you get a single 745 | response, other times you get a set of responses (result set). In order 746 | to establish a unified interface, we conform all results to the same 747 | format: a list of response objects. 748 | 749 | Additionally, the qbuf property is weird too. In the case of multiple 750 | responses, there's one qbuf for every response, but for single results, 751 | it's not stored in the result, but rather the outer result data. Again, 752 | for the purposes of uniformity, we shoehorn the qbuf into the first (and 753 | only) response in the latter case. 754 | """ 755 | 756 | responses = [] 757 | part_of_set = True 758 | 759 | # Account for single results 760 | if "result" in self.raw_data: 761 | if "qbuf" in self.raw_data: 762 | if "qbuf" not in self.raw_data["result"]: 763 | self.raw_data["result"]["qbuf"] = self.raw_data.pop("qbuf") 764 | responses.append(self.raw_data["result"]) 765 | part_of_set = False 766 | 767 | try: 768 | self.responses_total = int(self.raw_data["result"]["submax"]) 769 | except (KeyError, ValueError): 770 | pass # The value wasn't there, not much we can do about it 771 | 772 | try: 773 | responses += self.raw_data["resultset"] 774 | except KeyError: 775 | pass # self.responses remains the same 776 | 777 | return part_of_set, responses 778 | 779 | __all__ = ( 780 | "DnsResult", 781 | ) 782 | -------------------------------------------------------------------------------- /tests/test_ping.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 RIPE NCC 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from ripe.atlas.sagan import Result 17 | from ripe.atlas.sagan.ping import PingResult, Packet 18 | 19 | 20 | def test_ping_0(): 21 | result = Result.get('{"avg":"58.042","dst_addr":"62.2.16.12","dup":"0","fw":0,"max":"58.272","min":"57.876","msm_id":1000192,"prb_id":677,"rcvd":"3","sent":"3","src_addr":"78.128.9.202","timestamp":1328019792,"type":"ping"}') 22 | assert(result.af == 4) 23 | assert(result.rtt_average == 58.042) 24 | assert(result.rtt_median is None) 25 | assert(result.destination_address == "62.2.16.12") 26 | assert(result.destination_name is None) 27 | assert(result.duplicates == 0) 28 | assert(result.origin is None) 29 | assert(result.firmware == 0) 30 | assert(result.seconds_since_sync is None) 31 | assert(result.rtt_max == 58.272) 32 | assert(result.rtt_min == 57.876) 33 | assert(result.measurement_id == 1000192) 34 | assert(result.probe_id == 677) 35 | assert(result.protocol is None) 36 | assert(result.packets_received == 3) 37 | assert(result.packets_sent == 3) 38 | assert(result.packet_size is None) 39 | assert(result.step is None) 40 | assert(result.created.isoformat() == "2012-01-31T14:23:12+00:00") 41 | assert(result.packets == []) 42 | 43 | 44 | def test_ping_1(): 45 | result = Result.get('{"addr":"62.2.16.12","avg":124.572,"dup":0,"from":"194.85.27.7","fw":1,"max":125.44499999999999,"min":123.89400000000001,"msm_id":1000192,"name":"hsi.cablecom.ch","prb_id":165,"rcvd":3,"sent":3,"size":56,"timestamp":1340523908,"type":"ping"}') 46 | assert(result.af == 4) 47 | assert(result.rtt_average == 124.572) 48 | assert(result.rtt_median is None) 49 | assert(result.destination_address == "62.2.16.12") 50 | assert(result.destination_name == "hsi.cablecom.ch") 51 | assert(result.duplicates == 0) 52 | assert(result.origin == "194.85.27.7") 53 | assert(result.firmware == 1) 54 | assert(result.seconds_since_sync is None) 55 | assert(result.rtt_max == 125.445) 56 | assert(result.rtt_min == 123.894) 57 | assert(result.measurement_id == 1000192) 58 | assert(result.probe_id == 165) 59 | assert(result.protocol is None) 60 | assert(result.packets_received == 3) 61 | assert(result.packets_sent == 3) 62 | assert(result.packet_size is None) 63 | assert(result.step is None) 64 | assert(result.created.isoformat() == "2012-06-24T07:45:08+00:00") 65 | assert(result.packets == []) 66 | 67 | 68 | def test_ping_4460(): 69 | result = Result.get('{"addr":"62.2.16.12","af":4,"avg":48.388333333333328,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"188.194.234.136","fw":4460,"max":56.948999999999998,"min":43.869999999999997,"msm_id":1000192,"name":"hsi.cablecom.ch","prb_id":270,"proto":"ICMP","rcvd":3,"result":[{"rtt":43.869999999999997},{"rtt":56.948999999999998},{"rtt":44.345999999999997}],"sent":3,"size":20,"src_addr":"192.168.178.21","timestamp":1340524626,"ttl":52,"type":"ping"}') 70 | assert(result.af == 4) 71 | assert(result.rtt_average == 48.388) 72 | assert(result.rtt_median == 44.346) 73 | assert(result.destination_address == "62.2.16.12") 74 | assert(result.destination_name == "hsi.cablecom.ch") 75 | assert(result.duplicates == 0) 76 | assert(result.origin == "188.194.234.136") 77 | assert(result.firmware == 4460) 78 | assert(result.seconds_since_sync is None) 79 | assert(result.rtt_max == 56.949) 80 | assert(result.rtt_min == 43.87) 81 | assert(result.measurement_id == 1000192) 82 | assert(result.probe_id == 270) 83 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 84 | assert(result.packets_received == 3) 85 | assert(result.packets_sent == 3) 86 | assert(result.packet_size == 12) 87 | assert(result.step is None) 88 | assert(result.created.isoformat() == "2012-06-24T07:57:06+00:00") 89 | assert(result.packets[0].rtt == 43.87) 90 | assert(result.packets[1].rtt == 56.949) 91 | assert(result.packets[2].rtt == 44.346) 92 | assert(result.packets[0].ttl == 52) 93 | assert(result.packets[1].ttl == 52) 94 | assert(result.packets[2].ttl == 52) 95 | assert(result.packets[0].dup is False) 96 | assert(result.packets[1].dup is False) 97 | assert(result.packets[2].dup is False) 98 | assert(result.packets[0].source_address == "192.168.178.21") 99 | assert(result.packets[1].source_address == "192.168.178.21") 100 | assert(result.packets[2].source_address == "192.168.178.21") 101 | 102 | 103 | def test_ping_4470(): 104 | result = Result.get('{"addr":"62.2.16.12","af":4,"avg":195.649,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"194.85.27.7","fw":4470,"max":197.79300000000001,"min":193.059,"msm_id":1000192,"name":"hsi.cablecom.ch","prb_id":165,"proto":"ICMP","rcvd":3,"result":[{"rtt":196.095},{"rtt":197.79300000000001},{"rtt":193.059}],"sent":3,"size":20,"src_addr":"192.168.3.8","timestamp":1344514151,"ttl":46,"type":"ping"}') 105 | assert(result.af == 4) 106 | assert(result.rtt_average == 195.649) 107 | assert(result.rtt_median == 196.095) 108 | assert(result.destination_address == "62.2.16.12") 109 | assert(result.destination_name == "hsi.cablecom.ch") 110 | assert(result.duplicates == 0) 111 | assert(result.origin == "194.85.27.7") 112 | assert(result.firmware == 4470) 113 | assert(result.seconds_since_sync is None) 114 | assert(result.rtt_max == 197.793) 115 | assert(result.rtt_min == 193.059) 116 | assert(result.measurement_id == 1000192) 117 | assert(result.probe_id == 165) 118 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 119 | assert(result.packets_received == 3) 120 | assert(result.packets_sent == 3) 121 | assert(result.packet_size == 12) 122 | assert(result.step is None) 123 | assert(result.created.isoformat() == "2012-08-09T12:09:11+00:00") 124 | assert(result.packets[0].rtt == 196.095) 125 | assert(result.packets[1].rtt == 197.793) 126 | assert(result.packets[2].rtt == 193.059) 127 | assert(result.packets[0].ttl == 46) 128 | assert(result.packets[1].ttl == 46) 129 | assert(result.packets[2].ttl == 46) 130 | assert(result.packets[0].dup is False) 131 | assert(result.packets[1].dup is False) 132 | assert(result.packets[2].dup is False) 133 | assert(result.packets[0].source_address == "192.168.3.8") 134 | assert(result.packets[1].source_address == "192.168.3.8") 135 | assert(result.packets[2].source_address == "192.168.3.8") 136 | 137 | 138 | def test_ping_4480(): 139 | result = Result.get('{"addr":"62.2.16.12","af":4,"avg":95.756666666666661,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"194.85.27.7","fw":4480,"max":96.147999999999996,"min":95.388999999999996,"msm_id":1000192,"name":"hsi.cablecom.ch","prb_id":165,"proto":"ICMP","rcvd":3,"result":[{"rtt":95.733000000000004},{"rtt":96.147999999999996},{"rtt":95.388999999999996}],"sent":3,"size":20,"src_addr":"192.168.3.8","timestamp":1349776268,"ttl":46,"type":"ping"}') 140 | assert(result.af == 4) 141 | assert(result.rtt_average == 95.757) 142 | assert(result.rtt_median == 95.733) 143 | assert(result.destination_address == "62.2.16.12") 144 | assert(result.destination_name == "hsi.cablecom.ch") 145 | assert(result.duplicates == 0) 146 | assert(result.origin == "194.85.27.7") 147 | assert(result.firmware == 4480) 148 | assert(result.seconds_since_sync is None) 149 | assert(result.rtt_max == 96.148) 150 | assert(result.rtt_min == 95.389) 151 | assert(result.measurement_id == 1000192) 152 | assert(result.probe_id == 165) 153 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 154 | assert(result.packets_received == 3) 155 | assert(result.packets_sent == 3) 156 | assert(result.packet_size == 12) 157 | assert(result.step is None) 158 | assert(result.created.isoformat() == "2012-10-09T09:51:08+00:00") 159 | assert(result.packets[0].rtt == 95.733) 160 | assert(result.packets[1].rtt == 96.148) 161 | assert(result.packets[2].rtt == 95.389) 162 | assert(result.packets[0].ttl == 46) 163 | assert(result.packets[1].ttl == 46) 164 | assert(result.packets[2].ttl == 46) 165 | assert(result.packets[0].dup is False) 166 | assert(result.packets[1].dup is False) 167 | assert(result.packets[2].dup is False) 168 | assert(result.packets[0].source_address == "192.168.3.8") 169 | assert(result.packets[1].source_address == "192.168.3.8") 170 | assert(result.packets[2].source_address == "192.168.3.8") 171 | 172 | 173 | def test_ping_4500(): 174 | result = Result.get('{"addr":"62.2.16.12","af":4,"avg":30.114666666666665,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"80.56.151.3","fw":4500,"max":30.344999999999999,"min":29.960999999999999,"msm_id":1000192,"name":"hsi.cablecom.ch","prb_id":202,"proto":"ICMP","rcvd":3,"result":[{"rtt":30.038},{"rtt":29.960999999999999},{"rtt":30.344999999999999}],"sent":3,"size":20,"src_addr":"192.168.1.229","timestamp":1361244431,"ttl":55,"type":"ping"}') 175 | assert(result.af == 4) 176 | assert(result.rtt_average == 30.115) 177 | assert(result.rtt_median == 30.038) 178 | assert(result.destination_address == "62.2.16.12") 179 | assert(result.destination_name == "hsi.cablecom.ch") 180 | assert(result.duplicates == 0) 181 | assert(result.origin == "80.56.151.3") 182 | assert(result.firmware == 4500) 183 | assert(result.seconds_since_sync is None) 184 | assert(result.rtt_max == 30.345) 185 | assert(result.rtt_min == 29.961) 186 | assert(result.measurement_id == 1000192) 187 | assert(result.probe_id == 202) 188 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 189 | assert(result.packets_received == 3) 190 | assert(result.packets_sent == 3) 191 | assert(result.packet_size == 12) 192 | assert(result.step is None) 193 | assert(result.created.isoformat() == "2013-02-19T03:27:11+00:00") 194 | assert(result.packets[0].rtt == 30.038) 195 | assert(result.packets[1].rtt == 29.961) 196 | assert(result.packets[2].rtt == 30.345) 197 | assert(result.packets[0].ttl == 55) 198 | assert(result.packets[1].ttl == 55) 199 | assert(result.packets[2].ttl == 55) 200 | assert(result.packets[0].dup is False) 201 | assert(result.packets[1].dup is False) 202 | assert(result.packets[2].dup is False) 203 | assert(result.packets[0].source_address == "192.168.1.229") 204 | assert(result.packets[1].source_address == "192.168.1.229") 205 | assert(result.packets[2].source_address == "192.168.1.229") 206 | 207 | 208 | def test_ping_4520(): 209 | result = Result.get('{"addr":"62.2.16.12","af":4,"avg":67.420999999999992,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"194.85.27.7","fw":4520,"max":70.230999999999995,"min":65.974999999999994,"msm_id":1000192,"name":"hsi.cablecom.ch","prb_id":165,"proto":"ICMP","rcvd":3,"result":[{"rtt":70.230999999999995},{"rtt":65.974999999999994},{"rtt":66.057000000000002}],"sent":3,"size":20,"src_addr":"192.168.3.8","timestamp":1365379380,"ttl":47,"type":"ping"}') 210 | assert(result.af == 4) 211 | assert(result.rtt_average == 67.421) 212 | assert(result.rtt_median == 66.057) 213 | assert(result.destination_address == "62.2.16.12") 214 | assert(result.destination_name == "hsi.cablecom.ch") 215 | assert(result.duplicates == 0) 216 | assert(result.origin == "194.85.27.7") 217 | assert(result.firmware == 4520) 218 | assert(result.seconds_since_sync is None) 219 | assert(result.rtt_max == 70.231) 220 | assert(result.rtt_min == 65.975) 221 | assert(result.measurement_id == 1000192) 222 | assert(result.probe_id == 165) 223 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 224 | assert(result.packets_received == 3) 225 | assert(result.packets_sent == 3) 226 | assert(result.packet_size == 12) 227 | assert(result.step is None) 228 | assert(result.created.isoformat() == "2013-04-08T00:03:00+00:00") 229 | assert(result.packets[0].rtt == 70.231) 230 | assert(result.packets[1].rtt == 65.975) 231 | assert(result.packets[2].rtt == 66.057) 232 | assert(result.packets[0].ttl == 47) 233 | assert(result.packets[1].ttl == 47) 234 | assert(result.packets[2].ttl == 47) 235 | assert(result.packets[0].dup is False) 236 | assert(result.packets[1].dup is False) 237 | assert(result.packets[2].dup is False) 238 | assert(result.packets[0].source_address == "192.168.3.8") 239 | assert(result.packets[1].source_address == "192.168.3.8") 240 | assert(result.packets[2].source_address == "192.168.3.8") 241 | 242 | 243 | def test_ping_4550(): 244 | result = Result.get('{"af":4,"avg":27.300999999999998,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"80.56.151.3","fw":4550,"lts":365,"max":27.300999999999998,"min":27.300999999999998,"msm_id":1000192,"msm_name":"Ping","prb_id":202,"proto":"ICMP","rcvd":1,"result":[{"srcaddr":"192.168.1.229","x":"*"},{"x":"*"},{"rtt":27.300999999999998,"ttl":54}],"sent":3,"size":20,"src_addr":"192.168.1.229","step":360,"timestamp":1378271710,"ttl":54,"type":"ping"}') 245 | assert(result.af == 4) 246 | assert(result.rtt_average == 27.301) 247 | assert(result.rtt_median == 27.301) 248 | assert(result.destination_address == "62.2.16.12") 249 | assert(result.destination_name == "hsi.cablecom.ch") 250 | assert(result.duplicates == 0) 251 | assert(result.origin == "80.56.151.3") 252 | assert(result.firmware == 4550) 253 | assert(result.seconds_since_sync == 365) 254 | assert(result.rtt_max == 27.301) 255 | assert(result.rtt_min == 27.301) 256 | assert(result.measurement_id == 1000192) 257 | assert(result.probe_id == 202) 258 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 259 | assert(result.packets_received == 1) 260 | assert(result.packets_sent == 3) 261 | assert(result.packet_size == 12) 262 | assert(result.step == 360) 263 | assert(result.created.isoformat() == "2013-09-04T05:15:10+00:00") 264 | assert(result.packets[0].rtt is None) 265 | assert(result.packets[1].rtt is None) 266 | assert(result.packets[2].rtt == 27.301) 267 | assert(result.packets[0].ttl is None) 268 | assert(result.packets[1].ttl is None) 269 | assert(result.packets[2].ttl == 54) 270 | assert(result.packets[0].dup is False) 271 | assert(result.packets[1].dup is False) 272 | assert(result.packets[2].dup is False) 273 | assert(result.packets[0].source_address == "192.168.1.229") 274 | assert(result.packets[1].source_address == "192.168.1.229") 275 | assert(result.packets[2].source_address == "192.168.1.229") 276 | 277 | 278 | def test_ping_4560(): 279 | result = Result.get('{"af":4,"avg":36.887,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"62.195.143.53","fw":4560,"lts":36,"max":40.234000000000002,"min":34.747999999999998,"msm_id":1000192,"msm_name":"Ping","prb_id":202,"proto":"ICMP","rcvd":3,"result":[{"rtt":40.234000000000002},{"rtt":34.747999999999998,"srcaddr":"192.168.1.229"},{"rtt":35.679000000000002,"srcaddr":"192.168.1.229"}],"sent":3,"size":20,"src_addr":"192.168.1.229","step":360,"timestamp":1380586151,"ttl":54,"type":"ping"}') 280 | assert(result.af == 4) 281 | assert(result.rtt_average == 36.887) 282 | assert(result.rtt_median == 35.679) 283 | assert(result.destination_address == "62.2.16.12") 284 | assert(result.destination_name == "hsi.cablecom.ch") 285 | assert(result.duplicates == 0) 286 | assert(result.origin == "62.195.143.53") 287 | assert(result.firmware == 4560) 288 | assert(result.seconds_since_sync is 36) 289 | assert(result.rtt_max == 40.234) 290 | assert(result.rtt_min == 34.748) 291 | assert(result.measurement_id == 1000192) 292 | assert(result.probe_id == 202) 293 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 294 | assert(result.packets_received == 3) 295 | assert(result.packets_sent == 3) 296 | assert(result.packet_size == 12) 297 | assert(result.step == 360) 298 | assert(result.created.isoformat() == "2013-10-01T00:09:11+00:00") 299 | assert(result.packets[0].rtt == 40.234) 300 | assert(result.packets[1].rtt == 34.748) 301 | assert(result.packets[2].rtt == 35.679) 302 | assert(result.packets[0].ttl == 54) 303 | assert(result.packets[1].ttl == 54) 304 | assert(result.packets[2].ttl == 54) 305 | assert(result.packets[0].dup is False) 306 | assert(result.packets[1].dup is False) 307 | assert(result.packets[2].dup is False) 308 | assert(result.packets[0].source_address == "192.168.1.229") 309 | assert(result.packets[1].source_address == "192.168.1.229") 310 | assert(result.packets[2].source_address == "192.168.1.229") 311 | 312 | 313 | def test_ping_4570(): 314 | result = Result.get('{"af":4,"avg":36.608333333333327,"dst_addr":"62.2.16.12","dst_name":"hsi.cablecom.ch","dup":0,"from":"62.195.143.53","fw":4570,"lts":-1,"max":36.741,"min":36.423999999999999,"msm_id":1000192,"msm_name":"Ping","prb_id":202,"proto":"ICMP","rcvd":3,"result":[{"rtt":36.741},{"rtt":36.659999999999997},{"rtt":36.423999999999999}],"sent":3,"size":12,"step":360,"timestamp":1384500425, "type":"ping"}') 315 | assert(result.af == 4) 316 | assert(result.rtt_average == 36.608) 317 | assert(result.rtt_median == 36.66) 318 | assert(result.destination_address == "62.2.16.12") 319 | assert(result.destination_name == "hsi.cablecom.ch") 320 | assert(result.duplicates == 0) 321 | assert(result.origin == "62.195.143.53") 322 | assert(result.firmware == 4570) 323 | assert(result.seconds_since_sync is None) 324 | assert(result.rtt_max == 36.741) 325 | assert(result.rtt_min == 36.424) 326 | assert(result.measurement_id == 1000192) 327 | assert(result.probe_id == 202) 328 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 329 | assert(result.packets_received == 3) 330 | assert(result.packets_sent == 3) 331 | assert(result.packet_size == 12) 332 | assert(result.step == 360) 333 | assert(result.created.isoformat() == "2013-11-15T07:27:05+00:00") 334 | assert(result.packets[0].rtt == 36.741) 335 | assert(result.packets[1].rtt == 36.66) 336 | assert(result.packets[2].rtt == 36.424) 337 | assert(result.packets[0].ttl is None) 338 | assert(result.packets[1].ttl is None) 339 | assert(result.packets[2].ttl is None) 340 | assert(result.packets[0].dup is False) 341 | assert(result.packets[1].dup is False) 342 | assert(result.packets[2].dup is False) 343 | assert(result.packets[0].source_address is None) 344 | assert(result.packets[1].source_address is None) 345 | assert(result.packets[2].source_address is None) 346 | 347 | 348 | def test_ping_4600(): 349 | result = Result.get('{"af":4,"avg":47.951999999999998,"dst_addr":"62.2.16.24","dst_name":"hsi.cablecom.ch","dup":0,"from":"188.195.183.141","fw":4600,"group_id":1000192,"lts":222,"max":48.990000000000002,"min":45.939,"msm_id":1000192,"msm_name":"Ping","prb_id":270,"proto":"ICMP","rcvd":3,"result":[{"rtt":45.939},{"rtt":48.927},{"rtt":48.990000000000002}],"sent":3,"size":12,"src_addr":"192.168.178.21","step":360,"timestamp":1392321470,"ttl":50,"type":"ping"}') 350 | assert(result.af == 4) 351 | assert(result.rtt_average == 47.952) 352 | assert(result.rtt_median == 48.927) 353 | assert(result.destination_address == "62.2.16.24") 354 | assert(result.destination_name == "hsi.cablecom.ch") 355 | assert(result.duplicates == 0) 356 | assert(result.origin == "188.195.183.141") 357 | assert(result.firmware == 4600) 358 | assert(result.seconds_since_sync == 222) 359 | assert(result.rtt_max == 48.99) 360 | assert(result.rtt_min == 45.939) 361 | assert(result.measurement_id == 1000192) 362 | assert(result.probe_id == 270) 363 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 364 | assert(result.packets_received == 3) 365 | assert(result.packets_sent == 3) 366 | assert(result.packet_size == 12) 367 | assert(result.step == 360) 368 | assert(result.created.isoformat() == "2014-02-13T19:57:50+00:00") 369 | assert(result.packets[0].rtt == 45.939) 370 | assert(result.packets[1].rtt == 48.927) 371 | assert(result.packets[2].rtt == 48.99) 372 | assert(result.packets[0].ttl == 50) 373 | assert(result.packets[1].ttl == 50) 374 | assert(result.packets[2].ttl == 50) 375 | assert(result.packets[0].dup is False) 376 | assert(result.packets[1].dup is False) 377 | assert(result.packets[2].dup is False) 378 | assert(result.packets[0].source_address == "192.168.178.21") 379 | assert(result.packets[1].source_address == "192.168.178.21") 380 | assert(result.packets[2].source_address == "192.168.178.21") 381 | 382 | 383 | def test_ping_4610(): 384 | result = Result.get('{"af":4,"avg":57.140666666666668,"dst_addr":"62.2.16.24","dst_name":"hsi.cablecom.ch","dup":0,"from":"188.195.181.120","fw":4610,"group_id":1000192,"lts":93,"max":63.213000000000001,"min":47.941000000000003,"msm_id":1000192,"msm_name":"Ping","prb_id":270,"proto":"ICMP","rcvd":3,"result":[{"rtt":63.213000000000001},{"rtt":47.941000000000003,"ttl":51},{"rtt":60.268000000000001,"ttl":50}],"sent":3,"size":12,"src_addr":"192.168.178.21","step":360,"timestamp":1395416383,"ttl":50,"type":"ping"}') 385 | assert(isinstance(result, Result)) 386 | assert(isinstance(result, PingResult)) 387 | assert(isinstance(result.packets[0], Packet)) 388 | assert(isinstance(result.packets[1], Packet)) 389 | assert(isinstance(result.packets[2], Packet)) 390 | assert(result.af == 4) 391 | assert(result.rtt_average == 57.141) 392 | assert(result.rtt_median == 60.268) 393 | assert(result.destination_address == "62.2.16.24") 394 | assert(result.destination_name == "hsi.cablecom.ch") 395 | assert(result.duplicates == 0) 396 | assert(result.origin == "188.195.181.120") 397 | assert(result.firmware == 4610) 398 | assert(result.seconds_since_sync == 93) 399 | assert(result.rtt_max == 63.213) 400 | assert(result.rtt_min == 47.941) 401 | assert(result.measurement_id == 1000192) 402 | assert(result.probe_id == 270) 403 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 404 | assert(result.packets_received == 3) 405 | assert(result.packets_sent == 3) 406 | assert(result.packet_size == 12) 407 | assert(result.step == 360) 408 | assert(result.created.isoformat() == "2014-03-21T15:39:43+00:00") 409 | assert(result.packets[0].rtt == 63.213) 410 | assert(result.packets[1].rtt == 47.941) 411 | assert(result.packets[2].rtt == 60.268) 412 | assert(result.packets[0].ttl == 50) 413 | assert(result.packets[1].ttl == 51) 414 | assert(result.packets[2].ttl == 50) 415 | assert(result.packets[0].dup is False) 416 | assert(result.packets[1].dup is False) 417 | assert(result.packets[2].dup is False) 418 | assert(result.packets[0].source_address == "192.168.178.21") 419 | assert(result.packets[1].source_address == "192.168.178.21") 420 | assert(result.packets[2].source_address == "192.168.178.21") 421 | 422 | 423 | def test_ping_duplicate(): 424 | result = Result.get('{"af":4,"avg":27.768000000000001,"dst_addr":"62.2.16.24","dst_name":"hsi.cablecom.ch","dup":2,"from":"109.190.83.40","fw":4610,"lts":38,"max":27.768000000000001,"min":27.768000000000001,"msm_id":1000192,"msm_name":"Ping","prb_id":1216,"proto":"ICMP","rcvd":1,"result":[{"srcaddr":"192.168.103.130","x":"*"},{"dup":1,"rtt":36.454000000000001,"ttl":54},{"dup":1,"rtt":37.756},{"rtt":27.768000000000001}],"sent":2,"size":12,"src_addr":"192.168.103.130","step":360,"timestamp":1395277728,"ttl":54,"type":"ping"}') 425 | assert(result.af == 4) 426 | assert(result.rtt_average == 27.768) 427 | assert(result.rtt_median == 27.768) 428 | assert(result.destination_address == "62.2.16.24") 429 | assert(result.destination_name == "hsi.cablecom.ch") 430 | assert(result.duplicates == 2) 431 | assert(result.origin == "109.190.83.40") 432 | assert(result.firmware == 4610) 433 | assert(result.seconds_since_sync == 38) 434 | assert(result.rtt_max == 27.768) 435 | assert(result.rtt_min == 27.768) 436 | assert(result.measurement_id == 1000192) 437 | assert(result.probe_id == 1216) 438 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 439 | assert(result.packets_received == 1) 440 | assert(result.packets_sent == 2) 441 | assert(result.packet_size == 12) 442 | assert(result.step == 360) 443 | assert(result.created.isoformat() == "2014-03-20T01:08:48+00:00") 444 | assert(result.packets[0].rtt is None) 445 | assert(result.packets[1].rtt == 36.454) 446 | assert(result.packets[2].rtt == 37.756) 447 | assert(result.packets[3].rtt == 27.768) 448 | assert(result.packets[0].ttl is None) 449 | assert(result.packets[1].ttl == 54) 450 | assert(result.packets[2].ttl == 54) 451 | assert(result.packets[3].ttl == 54) 452 | assert(result.packets[0].dup is False) 453 | assert(result.packets[1].dup is True) 454 | assert(result.packets[2].dup is True) 455 | assert(result.packets[3].dup is False) 456 | assert(result.packets[0].source_address == "192.168.103.130") 457 | assert(result.packets[1].source_address == "192.168.103.130") 458 | assert(result.packets[2].source_address == "192.168.103.130") 459 | assert(result.packets[3].source_address == "192.168.103.130") 460 | 461 | 462 | def test_ping_buggy(): 463 | result = Result.get('{"af":4,"avg":-1,"dst_addr":"62.2.16.24","dst_name":"hsi.cablecom.ch","dup":2,"from":"62.195.143.53","fw":4600,"lts":130,"max":-1,"min":-1,"msm_id":1000192,"msm_name":"Ping","prb_id":202,"proto":"ICMP","rcvd":0,"result":[{"srcaddr":"192.168.1.229","x":"*"},{"dup":1,"rtt":1635.423,"ttl":55},{"dup":1,"rtt":1800.4939999999999}],"sent":1,"size":12,"src_addr":"192.168.1.229","step":360,"timestamp":1394745831,"ttl":55,"type":"ping"}') 464 | assert(result.af == 4) 465 | assert(result.rtt_average is None) 466 | assert(result.rtt_median is None) 467 | assert(result.destination_address == "62.2.16.24") 468 | assert(result.destination_name == "hsi.cablecom.ch") 469 | assert(result.duplicates == 2) 470 | assert(result.origin == "62.195.143.53") 471 | assert(result.firmware == 4600) 472 | assert(result.seconds_since_sync == 130) 473 | assert(result.rtt_max is None) 474 | assert(result.rtt_min is None) 475 | assert(result.measurement_id == 1000192) 476 | assert(result.probe_id == 202) 477 | assert(result.protocol == PingResult.PROTOCOL_ICMP) 478 | assert(result.packets_received == 0) 479 | assert(result.packets_sent == 1) 480 | assert(result.packet_size == 12) 481 | assert(result.step == 360) 482 | print(result.created, type(result.created)) 483 | print(result.created.isoformat()) 484 | assert(result.created.isoformat() == "2014-03-13T21:23:51+00:00") 485 | assert(result.packets[0].rtt is None) 486 | assert(result.packets[1].rtt == 1635.423) 487 | assert(result.packets[2].rtt == 1800.494) 488 | assert(result.packets[0].ttl is None) 489 | assert(result.packets[1].ttl == 55) 490 | assert(result.packets[2].ttl == 55) 491 | assert(result.packets[0].dup is False) 492 | assert(result.packets[1].dup is True) 493 | assert(result.packets[2].dup is True) 494 | assert(result.packets[0].source_address == "192.168.1.229") 495 | assert(result.packets[1].source_address == "192.168.1.229") 496 | assert(result.packets[2].source_address == "192.168.1.229") 497 | 498 | 499 | def test_ping_lts(): 500 | result = Result.get('{"af":4,"prb_id":270,"result":[{"rtt":70.265},{"rtt":54.584,"ttl":51},{"rtt":52.875}],"ttl":51,"avg":59.2413333333,"size":12,"from":"188.193.157.75","proto":"ICMP","timestamp":1406561624,"dup":0,"type":"ping","sent":3,"msm_id":1000192,"fw":4650,"max":70.265,"step":360,"src_addr":"192.168.178.21","rcvd":3,"msm_name":"Ping","lts":76,"dst_name":"hsi.cablecom.ch","min":52.875,"group_id":1000192,"dst_addr":"62.2.16.24"}') 501 | assert(result.seconds_since_sync == 76) 502 | --------------------------------------------------------------------------------