././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1740568315.573889 blaeu-2.2/0000775000175000017500000000000014757573374012735 5ustar00stephanestephane././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1740568195.0 blaeu-2.2/Blaeu.py0000664000175000017500000007637614757573203014351 0ustar00stephanestephane#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ A module to perform measurements on the RIPE Atlas probes using the UDM (User Defined Measurements) creation API. Authorization key is expected in $HOME/.atlas/auth in the environment variable ATLASAUTH or have to be provided in the constructor's arguments. Stéphane Bortzmeyer """ # WARNING: if you modify it here, also change setup.py https://packaging.python.org/guides/single-sourcing-package-version/#single-sourcing-the-version VERSION = '2.2' import os import json import time import urllib.request, urllib.error, urllib.parse import random import re import copy import sys import getopt import string import enum import socket authfile = "%s/.atlas/auth" % os.environ['HOME'] base_url = "https://atlas.ripe.net/api/v2/measurements" base_url_probes = "https://atlas.ripe.net/api/v2/probes" # The following parameters are currently not settable. Anyway, be # careful when changing these, you may get inconsistent results if you # do not wait long enough. Other warning: the time to wait depend on # the number of the probes. # All in seconds: fields_delay_base = 6 fields_delay_factor = 0.2 results_delay_base = 3 results_delay_factor = 0.15 maximum_time_for_results_base = 30 maximum_time_for_results_factor = 5 # The basic problem is that there is no easy way in Atlas to know when # it is over, either for retrieving the list of the probes, or for # retrieving the results themselves. The only solution is to wait # "long enough". The time to wait is not documented so the values # above have been found mostly with trial-and-error. class AuthFileNotFound(Exception): pass class AuthFileEmpty(Exception): pass class RequestSubmissionError(Exception): def __init__(self, http_status, reason, body): self.status = http_status self.reason = reason self.body = body class FieldsQueryError(Exception): pass class MeasurementNotFound(Exception): pass class MeasurementAccessError(Exception): pass class ProbeNotFound(Exception): pass class ProbeAccessError(Exception): pass class ResultError(Exception): pass class IncompatibleArguments(Exception): pass class InternalError(Exception): pass # Resut JSON file does not have the expected fields/members class WrongAssumption(Exception): pass # Utilities def format_error(error): msg = "" if error.status is not None: msg += "HTTP status %s " % error.status msg += error.reason if error.body is not None and error.body != "": msg += " %s" % error.body return msg Host_Type = enum.Enum('Host-Type', ['IPv6', 'IPv4', 'Name']) def host_type(s): """Takes an argument which is the identifier for a host and returns a host type. """ try: addr = socket.inet_pton(socket.AF_INET6, s) return Host_Type.IPv6 except socket.error: # not a valid IPv6 address try: addr = socket.inet_pton(socket.AF_INET, s) # Note that it # fails with unusual but common syntaxes such as raw # integers. return Host_Type.IPv4 except socket.error: # not a valid IPv4 address either return Host_Type.Name class Config: def __init__(self): # Default values self.old_measurement = None self.measurement_id = None self.probes = None self.country = None # World-wide self.asn = None # All self.area = None # World-wide self.prefix = None self.verbose = False self.requested = 5 # Probes self.default_requested = True self.percentage_required = 0.9 self.machine_readable = False self.measurement_id = None self.display_probes = False self.display_probe_asns = False self.cache_probes = None self.ipv4 = False self.private = False self.resolve_on_probe = False self.tags = None # Tags we send to tag the measurement, not tags of the probes self.port = 80 self.size = 64 self.spread = None # Tags self.exclude = None self.include = None def usage(self, msg=None): if msg: print(msg, file=sys.stderr) print("""General options are: --verbose or -v : makes the program more talkative --help or -h : this message --displayprobes or -o : display the probes numbers (WARNING: big lists) --displayprobeasns : display the (unique) probe ASNumbers (currently only for blaeu-resolve) --cache-probes= : cache probe data --country=2LETTERSCODE or -c 2LETTERSCODE : limits the measurements to one country (default is world-wide) --area=AREACODE or -a AREACODE : limits the measurements to one area such as North-Central (default is world-wide) --asn=ASnumber or -n ASnumber : limits the measurements to one AS (default is all ASes) --prefix=IPprefix or -f IPprefix : limits the measurements to one IP prefix (default is all prefixes) WARNING: it must be an *exact* prefix in the global routing table --probes=N or -s N : selects the probes by giving explicit ID (one ID or a comma-separated list) --requested=N or -r N : requests N probes (default is %s) --percentage=X or -p X : stops the program as soon as X %% of the probes reported a result (default is %s %%) --measurement-ID=N or -m N : do not start a measurement, just analyze a former one --old-measurement MSMID or -g MSMID : uses the probes of measurement MSMID --include TAGS or -i TAGS : limits the measurements to probes with these tags (a comma-separated list) --exclude TAGS or -e TAGS : excludes from measurements the probes with these tags (a comma-separated list) --port=N or -t N : destination port for TCP (default is %s) --size=N or -z N : number of bytes in the packet (default is %s bytes) --ipv4 or -4 : uses IPv4 (default is IPv6, except if the parameter or option is an IP address, then it is automatically found) --tags TAGS : tag the measurement (no relationship with probes tags) (a comma-separated list) --spread or -w : spreads the tests (add a delay before the tests) --private : makes the measurement private --resolve-on-probe : resolve names with the probe's DNS resolver --machine-readable or -b : machine-readable output, to be consumed by tools like grep or cut """ % (self.requested, int(self.percentage_required*100), self.port, self.size), file=sys.stderr) def parse(self, shortOptsSpecific="", longOptsSpecific=[], parseSpecific=None, usage=None): if usage is None: usage = self.usage try: # We keep some old syntaxes that were legal in the past # such as --old_measurement (underscore) or # --machineredable). optlist, args = getopt.getopt (sys.argv[1:], "4a:bc:e:f:g:hi:m:n:op:r:s:t:uvw:z:" + shortOptsSpecific, ["requested=", "country=", "area=", "asn=", "prefix=", "cache-probes=", "probes=", "port=", "percentage=", "include=", "exclude=", "version", "measurement-ID=", "old-measurement=", "old_measurement=", "display-probes", "displayprobes", "displayprobeasns", "size=", "ipv4", "private", "resolve-on-probe", "machine-readable", "machinereadable", "spread=", "verbose", "tags=", "help"] + longOptsSpecific) for option, value in optlist: if option == "--country" or option == "-c": self.country = value elif option == "--area" or option == "-a": self.area = value elif option == "--asn" or option == "-n": self.asn = value elif option == "--prefix" or option == "-f": self.prefix = value elif option == "--cache-probes": self.cache_probes = value elif option == "--probes" or option == "-s": self.probes = value # Splitting (and syntax checking...) delegated to Atlas elif option == "--percentage" or option == "-p": self.percentage_required = float(value) elif option == "--requested" or option == "-r": self.requested = int(value) self.default_requested = False elif option == "--port" or option == "-t": self.port = int(value) elif option == "--measurement-ID" or option == "-m": self.measurement_id = value elif option == "--old-measurement" or option == "--old_measurement" or option == "-g": self.old_measurement = value elif option == "--verbose" or option == "-v": self.verbose = True elif option == "--ipv4" or option == "-4": self.ipv4 = True elif option == "--private": self.private = True elif option == "--resolve-on-probe" or option == "-u": self.resolve_on_probe = True elif option == "--size" or option == "-z": self.size = int(value) elif option == "--spread" or option == "-w": self.spread = int(value) elif option == "--display-probes" or option == "--displayprobes" or option == "-o": self.display_probes = True elif option == "--displayprobeasns": self.display_probe_asns = True elif option == "--exclude" or option == "-e": self.exclude = value.split(",") elif option == "--include" or option == "-i": # See the file TAGS self.include = value.split(",") elif option == "--tags": self.tags = value.split(",") elif option == "--machine-readable" or option == "--machinereadable" or option == "-b": self.machine_readable = True elif option == "--help" or option == "-h": usage() sys.exit(0) elif option == "--version": print("Blaeu version %s" % VERSION) sys.exit(0) else: parseResult = parseSpecific(self, option, value) if not parseResult: usage("Unknown option %s" % option) sys.exit(1) except getopt.error as reason: usage(reason) sys.exit(1) if self.country is not None: if self.asn is not None or self.area is not None or self.prefix is not None or \ self.probes is not None: usage("Specify country *or* area *or* ASn *or* prefix *or* the list of probes") sys.exit(1) elif self.area is not None: if self.asn is not None or self.country is not None or self.prefix is not None or \ self.probes is not None: usage("Specify country *or* area *or* ASn *or* prefix *or* the list of probes") sys.exit(1) elif self.asn is not None: if self.area is not None or self.country is not None or self.prefix is not None or \ self.probes is not None: usage("Specify country *or* area *or* ASn *or* prefix *or* the list of probes") sys.exit(1) elif self.probes is not None: if self.country is not None or self.area is not None or self.asn or \ self.prefix is not None: usage("Specify country *or* area *or* ASn *or* prefix *or* the list of probes") sys.exit(1) elif self.prefix is not None: if self.country is not None or self.area is not None or self.asn or \ self.probes is not None: usage("Specify country *or* area *or* ASn *or* prefix *or* the list of probes") sys.exit(1) if self.probes is not None or self.old_measurement is not None: if not self.default_requested: print("Warning: --requested=%d ignored since a list of probes was requested" % self.requested, file=sys.stderr) if self.old_measurement is not None: def ignored(variable, name): if variable is not None: print("Warning: --%s ignored since we use probes from a previous measurement" % name, file=sys.stderr) ignored(self.country, "country") ignored(self.area, "area") ignored(self.prefix, "prefix") ignored(self.asn, "asn") ignored(self.probes, "probes") ignored(self.include, "include") ignored(self.exclude, "exclude") if self.probes is not None: self.requested = len(self.probes.split(",")) data = { "is_oneoff": True, "definitions": [ {"description": "", "port": self.port} ], "probes": [ {"requested": self.requested} ] } if self.old_measurement is not None: old_measurement = Measurement(data=None, id=self.old_measurement) data["probes"][0]["requested"] = old_measurement.num_probes if self.verbose: print("Using %i probes from measurement #%s" % \ (data["probes"][0]["requested"], self.old_measurement)) data["probes"][0]["type"] = "msm" data["probes"][0]["value"] = self.old_measurement data["definitions"][0]["description"] += (" from probes of measurement #%s" % self.old_measurement) else: if self.probes is not None: data["probes"][0]["type"] = "probes" data["probes"][0]["value"] = self.probes else: if self.country is not None: data["probes"][0]["type"] = "country" data["probes"][0]["value"] = self.country data["definitions"][0]["description"] += (" from %s" % self.country) elif self.area is not None: data["probes"][0]["type"] = "area" data["probes"][0]["value"] = self.area data["definitions"][0]["description"] += (" from %s" % self.area) elif self.asn is not None: data["probes"][0]["type"] = "asn" data["probes"][0]["value"] = self.asn data["definitions"][0]["description"] += (" from AS #%s" % self.asn) elif self.prefix is not None: data["probes"][0]["type"] = "prefix" data["probes"][0]["value"] = self.prefix data["definitions"][0]["description"] += (" from prefix %s" % self.prefix) else: data["probes"][0]["type"] = "area" data["probes"][0]["value"] = "WW" if self.ipv4: data["definitions"][0]['af'] = 4 else: data["definitions"][0]['af'] = 6 if self.private: data["definitions"][0]['is_public'] = False if self.resolve_on_probe: data["definitions"][0]['resolve_on_probe'] = True if self.size is not None: data["definitions"][0]['size'] = self.size if self.tags is not None: data["definitions"][0]["tags"] = self.tags if self.spread is not None: data["definitions"][0]['spread'] = self.spread data["probes"][0]["tags"] = {} if self.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(self.include) else: data["probes"][0]["tags"]["include"] = [] if self.ipv4: data["probes"][0]["tags"]["include"].append("system-ipv4-works") # Some probes cannot do ICMP outgoing (firewall?) else: data["probes"][0]["tags"]["include"].append("system-ipv6-works") if self.exclude is not None: data["probes"][0]["tags"]["exclude"] = copy.copy(self.exclude) if self.verbose: print("Blaeu version %s" % VERSION) return args, data class JsonRequest(urllib.request.Request): def __init__(self, url, key): urllib.request.Request.__init__(self, url) self.url = url self.add_header("Content-Type", "application/json") self.add_header("Accept", "application/json") self.add_header("User-Agent", "Blaeu/%s" % VERSION) self.add_header("Authorization", "Key %s" % key) #To debug, uncomment this line: #print(self.header_items()) #print(self) will display the URL called def __str__(self): return self.url class Measurement(): """ An Atlas measurement, identified by its ID (such as #1010569) in the field "id" """ def __init__(self, data, wait=True, sleep_notification=None, key=None, id=None): """ Creates a measurement."data" must be a dictionary (*not* a JSON string) having the members requested by the Atlas documentation. "wait" should be set to False for periodic (not oneoff) measurements. "sleep_notification" is a lambda taking one parameter, the sleep delay: when the module has to sleep, it calls this lambda, allowing you to be informed of the delay. "key" is the API key. If None, it will be read in the configuration file. If "data" is None and id is not, a dummy measurement will be created, mapped to the existing measurement having this ID. """ if data is None and id is None: raise RequestSubmissionError(None, "No data and no measurement ID", None) # TODO: when creating a dummy measurement, a key may not be necessary if the measurement is public if not key: if os.environ.get("ATLASAUTH"): # use envvar ATLASAUTH for the key key = os.environ.get("ATLASAUTH") else: # use file for key if envvar ATLASAUTH isn't set if not os.path.exists(authfile): raise AuthFileNotFound("Authentication file %s not found" % authfile) auth = open(authfile) key = auth.readline() if key is None or key == "": raise AuthFileEmpty("Authentication file %s empty or missing a end-of-line at the end" % authfile) key = key.rstrip('\n') auth.close() self.url = base_url self.url_probes = base_url + "/%s/?fields=probes,status" self.url_status = base_url + "/%s/?fields=status" self.url_results = base_url + "/%s/results/" self.url_all = base_url + "/%s/" self.url_latest = base_url + "-latest/%s/?versions=%s" self.key = key self.status = None if data is not None: self.json_data = json.dumps(data).encode('utf-8') self.notification = sleep_notification request = JsonRequest(self.url, self.key) try: # Start the measurement conn = urllib.request.urlopen(request, self.json_data) # To debug, uncomment these two lines: #headers = conn.getheaders() #print(headers) # Now, parse the answer results = json.loads(conn.read().decode('utf-8')) self.id = results["measurements"][0] conn.close() except urllib.error.HTTPError as e: raise RequestSubmissionError(e.code, e.reason, e.read().decode()) except urllib.error.URLError as e: raise RequestSubmissionError(None, e.reason, "URL %s" % self.url) self.gen = random.Random() self.time = time.gmtime() if not wait: return # Find out how many probes were actually allocated to this measurement enough = False left = 30 # Maximum number of tests requested = data["probes"][0]["requested"] fields_delay = fields_delay_base + (requested * fields_delay_factor) while not enough: # Let's be patient if self.notification is not None: self.notification(fields_delay) time.sleep(fields_delay) fields_delay *= 2 try: request = JsonRequest((self.url_probes % self.id) + \ ("&defeatcaching=dc%s" % self.gen.randint(1,10000)), self.key) # A random # component is necesary to defeat caching (even Cache-Control sems ignored) conn = urllib.request.urlopen(request) # Now, parse the answer meta = json.loads(conn.read().decode('utf-8')) self.status = meta["status"]["name"] if meta["status"]["name"] == "Specified" or \ meta["status"]["name"] == "Scheduled": # Not done, loop left -= 1 if left <= 0: raise FieldsQueryError("Maximum number of status queries reached") elif meta["status"]["name"] == "Ongoing": enough = True self.num_probes = len(meta["probes"]) else: raise InternalError("Internal error in #%s, unexpected status when querying the measurement fields: \"%s\"" % (self.id, meta["status"])) conn.close() except urllib.error.URLError as e: raise FieldsQueryError("%s" % e.reason) else: self.id = id self.notification = None try: conn = urllib.request.urlopen(JsonRequest(self.url_status % self.id, self.key)) except urllib.error.HTTPError as e: if e.code == 404: raise MeasurementNotFound else: raise MeasurementAccessError("HTTP %s, %s %s" % (e.code, e.reason, e.read())) except urllib.error.URLError as e: raise MeasurementAccessError("Reason \"%s\"" % \ (e.reason)) result_status = json.loads(conn.read().decode('utf-8')) status = result_status["status"]["name"] self.status = status if status != "Ongoing" and status != "Stopped": raise MeasurementAccessError("Invalid status \"%s\"" % status) try: conn = urllib.request.urlopen(JsonRequest(self.url_probes % self.id, self.key)) except urllib.error.HTTPError as e: if e.code == 404: raise MeasurementNotFound else: raise MeasurementAccessError("%s %s" % (e.reason, e.read())) except urllib.error.URLError as e: raise MeasurementAccessError("Reason \"%s\"" % \ (e.reason)) result_status = json.loads(conn.read().decode('utf-8')) self.num_probes = len(result_status["probes"]) try: conn = urllib.request.urlopen(JsonRequest(self.url_all % self.id, self.key)) except urllib.error.HTTPError as e: if e.code == 404: raise MeasurementNotFound else: raise MeasurementAccessError("%s %s" % (e.reason, e.read())) except urllib.error.URLError as e: raise MeasurementAccessError("Reason \"%s\"" % \ (e.reason)) result_status = json.loads(conn.read().decode('utf-8')) self.time = time.gmtime(result_status["start_time"]) self.description = result_status["description"] self.interval = result_status["interval"] def results(self, wait=True, percentage_required=0.9, latest=None): """Retrieves the result. "wait" indicates if you are willing to wait until the measurement is over (otherwise, you'll get partial results). "percentage_required" is meaningful only when you wait and it indicates the percentage of the allocated probes that have to report before the function returns (warning: the measurement may stop even if not enough probes reported so you always have to check the actual number of reporting probes in the result). "latest" indicates that you want to retrieve only the last N results (by default, you get all the results). """ if latest is not None: wait = False if latest is None: request = JsonRequest(self.url_results % self.id, self.key) else: request = JsonRequest(self.url_latest % (self.id, latest), self.key) if wait: enough = False attempts = 0 results_delay = results_delay_base + (self.num_probes * results_delay_factor) maximum_time_for_results = maximum_time_for_results_base + \ (self.num_probes * maximum_time_for_results_factor) start = time.time() elapsed = 0 result_data = None while not enough and elapsed < maximum_time_for_results: if self.notification is not None: self.notification(results_delay) time.sleep(results_delay) results_delay *= 2 attempts += 1 elapsed = time.time() - start try: conn = urllib.request.urlopen(request) result_data = json.loads(conn.read().decode('utf-8')) num_results = len(result_data) if num_results >= self.num_probes*percentage_required: # Requesting a strict equality may be too # strict: if an allocated probe does not # respond, we will have to wait for the stop # of the measurement (many minutes). Anyway, # there is also the problem that a probe may # have sent only a part of its measurements. enough = True else: conn = urllib.request.urlopen(JsonRequest(self.url_status % self.id, self.key)) result_status = json.loads(conn.read().decode('utf-8')) status = result_status["status"]["name"] if status == "Ongoing": # Wait a bit more pass elif status == "Stopped": enough = True # Even if not enough probes else: raise InternalError("Unexpected status when retrieving the measurement: \"%s\"" % \ result_data["status"]) conn.close() except urllib.error.HTTPError as e: if e.code != 404: # Yes, we may have no result file at # all for some time raise ResultError(str(e.code) + " " + e.reason + " " + str(e.read())) except urllib.error.URLError as e: raise ResultError("Reason \"%s\"" % \ (e.reason)) if result_data is None: raise ResultError("No results retrieved") else: try: conn = urllib.request.urlopen(request) result_data = json.loads(conn.read().decode('utf-8')) except urllib.error.URLError as e: raise ResultError(e.reason) return result_data class Probe(): """ An Atlas probe, identified by its ID (such as #1010569) in the field "id" """ __probe_attributes = { "id": "id", "status": "status.name", "address_v4": "address_v4", "address_v6": "address_v6", "asn_v4": "asn_v4", "asn_v6": "asn_v6", "country_code": "country_code", "is_public": "is_public", "prefix_v4": "prefix_v4", "prefix_v6": "prefix_v6", "tags": "tags", "description": "description", "geometry": "geometry", "first_connected": "first_connected", "last_connected": "last_connected", } def __init__(self, id, fetch=True): if not fetch: self.id = id return self.url = base_url_probes + "/%s" % id try: conn = urllib.request.urlopen(JsonRequest(self.url, self.key)) except urllib.error.HTTPError as e: if e.code == 404: raise ProbeNotFound else: raise ProbeAccessError("HTTP %s, %s %s" % (e.code, e.reason, e.read())) except urllib.error.URLError as e: raise ProbeAccessError("Reason \"%s\"" % \ (e.reason)) probe_data = json.loads(conn.read().decode('utf-8')) def nested_get(d, keys): k = keys.pop(0) d = d[k] return nested_get(d, keys) if keys else d for key, lookup_key in self.__probe_attributes.items(): setattr(self, key, nested_get(probe_data, lookup_key.split("."))) def __str__(self): return "Probe #%s, %s" % (self.id, self.description) def __repr__(self): return "Probe #%s, %s" % (self.id, self.description) def __eq__(self, other): return self.__dict__ == other.__dict__ @classmethod def from_dict(cls, data): """Creates a Probe object from a dictionary (not a JSON string)""" if not all(k in data for k in cls.__probe_attributes): return probe = cls(data["id"], fetch=False) for key, value in data.items(): setattr(probe, key, value) return probe class ProbeCache(): """ A cache for probe data, to avoid querying the Atlas servers each time. """ __cache__ = {} def __init__(self, filename): self.filename = filename self.data = {} if not os.path.exists(filename) else \ json.load(open(filename), object_hook=lambda o: Probe.from_dict(o) or o) def __str__(self): return "ProbeCache %s, %s probes" % (self.filename, len(self.data)) def __repr__(self): return "ProbeCache %s, %s probes" % (self.filename, len(self.data)) def get(self, probe): return self.data.get(str(probe.id)) def put(self, probe): prev_data = self.data.copy() self.data[str(probe.id)] = probe if self.data != prev_data: json.dump(self.data, open(self.filename, "w"), default=lambda o: o.__dict__) return probe @classmethod def cache_probe_id(cls, filename, id): cache = cls.__cache__.setdefault(filename, cls(filename)) return cache.get(Probe(id)) or cache.put(Probe(id)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725631510.0 blaeu-2.2/EXAMPLES.md0000664000175000017500000002226314666606026014470 0ustar00stephanestephane# Examples of using Blaeu For a general introduction, see [the main documentation](README.md) or [my article at RIPE Labs](https://labs.ripe.net/Members/stephane_bortzmeyer/creating-ripe-atlas-one-off-measurements-with-blaeu). **Important**: remember you'll need Atlas credits and an API key. See [the main documentation](README.md) about that. ## blaeu-reach It tests the reachability of the target with ICMP echo packets, like the traditional `ping`. The only mandatory argument is an IP address (*not* a domain name). Basic use, when everything is OK: ``` % blaeu-reach 217.70.184.38 5 probes reported Test #78817889 done at 2024-09-06T13:11:02Z Tests: 15 successful tests (100.0 %), 0 errors (0.0 %), 0 timeouts (0.0 %), average RTT: 90 ms ``` By default, you get 5 probes, and 3 tests per probe (hence the 15 tests). More probes, but less tests per probe: ``` % blaeu-reach --requested 10 --tests 1 2606:4700::6812:eb44 10 probes reported Test #78817934 done at 2024-09-06T13:14:47Z Tests: 10 successful tests (100.0 %), 0 errors (0.0 %), 0 timeouts (0.0 %), average RTT: 23 ms ``` Of course, sometimes, there are problems, so you do not have 100 % success: ``` % blaeu-reach --requested 200 2001:470:0:149::2 200 probes reported Test #78817974 done at 2024-09-06T13:18:00Z Tests: 588 successful tests (98.0 %), 0 errors (0.0 %), 12 timeouts (2.0 %), average RTT: 146 ms ``` To get more details, we will process the existing measurement, asking to display the faulty probes: ``` % blaeu-reach --requested 200 --measurement-ID 78817974 --by_probe --displayprobes 2001:470:0:149::2 Test #78817974 done at 2024-09-06T13:18:00Z Tests: 196 successful probes (98.0 %), 4 failed (2.0 %), average RTT: 146 ms [1006805, 61970, 6890, 7016] ``` You can now search the [Atlas Web site](https://atlas.ripe.net/) to see what these probes have in common. There are global options, valid for every Blaeu command, like `--requested` or ``--measurement-ID`` above, and command-specific options, like `--tests` above. Calling a command with the `--help` option will give you the list. ## Probe selection A great feature of Atlas probes is the ability to select probes based on various criteria. This is possible with global options. For network issues, you will typically select based on AS or IP prefix: ``` % blaeu-reach --requested 10 --as 3215 2001:41d0:404:200::2df6 10 probes reported Test #78817989 done at 2024-09-06T13:19:26Z Tests: 30 successful tests (100.0 %), 0 errors (0.0 %), 0 timeouts (0.0 %), average RTT: 57 ms ``` For more political issues, you will probably use the country or the area (see examples when talking about `blaeu-resolve`). You can also re-use the probes of a previous measurement with ``--old-measurement`. ## blaeu-traceroute Like the traditional traceroute, it displays routers from the probes to the target (which must be an IP address). By default, you will have to see the results on the Atlas Web site so you'll probably almost always use the option `--format` for immediate display: ``` % blaeu-traceroute --requested 3 --format 160.92.168.33 … Test #78818145 done at 2024-09-06T13:29:11Z From: 41.136.159.117 23889 MauritiusTelecom, MU Source address: 192.168.100.90 Probe ID: 64274 1 192.168.100.1 NA NA [1.758, 1.456, 1.266] 2 197.226.230.71 23889 MauritiusTelecom, MU [3.343, 2.863, 2.624] 3 197.224.187.63 23889 MauritiusTelecom, MU [3.388, 3.81, 2.857] 4 197.226.230.79 23889 MauritiusTelecom, MU [135.356, 135.622, 135.742] 5 197.226.230.12 23889 MauritiusTelecom, MU [78.111, 78.315, 78.051] 6 180.87.105.48 6453 AS6453, US [135.666, 135.546, 135.677] 7 63.243.180.72 6453 AS6453, US [77.373, 77.451, 78.27] 8 129.250.66.9 2914 NTT-LTD-2914, US [130.727, 133.305, 134.793] 9 ['*', '*', '*'] 10 129.250.7.9 2914 NTT-LTD-2914, US [334.317, '*', 322.722] 11 129.250.4.188 2914 NTT-LTD-2914, US [366.291, 371.532, 367.035] 12 129.250.4.174 2914 NTT-LTD-2914, US [367.325, 369.696, 365.832] 13 128.241.6.223 2914 NTT-LTD-2914, US [255.407, 257.445, 251.827] 14 10.60.17.199 NA NA [282.123, 286.52, 284.159] 15 10.60.17.199 NA NA [283.969, 286.804, 285.138] 16 ['*', '*', '*'] 17 ['*', '*', '*'] 18 ['*', '*', '*'] 19 ['*', '*', '*'] 20 ['*', '*', '*'] 255 ['*', '*', '*'] … ``` It displays the IP address of the router, its AS number and name, its country, then the three response times. By default, it uses UDP packets but you can change, which is useful for targets that block UDP and/or returned ICMP packets. Here, since the target is a HTTP server, we use TCP and port 80: ``` % blaeu-traceroute --requested 3 --old-measurement 78818145 --format --protocol TCP --port 80 160.92.168.33 … Measurement #78818277 Traceroute 160.92.168.33 from probes of measurement #78818145 uses 3 probes … From: 41.136.159.117 23889 MauritiusTelecom, MU Source address: 192.168.100.90 Probe ID: 64274 1 192.168.100.1 NA NA [1.764, 0.854, 0.738] 2 197.226.230.71 23889 MauritiusTelecom, MU [2.936, 2.746, 2.284] 3 197.224.187.63 23889 MauritiusTelecom, MU [3.159, 3.281, 3.298] 4 197.226.230.81 23889 MauritiusTelecom, MU [79.637, 79.223, 79.214] 5 197.226.230.0 23889 MauritiusTelecom, MU [78.875, 79.159, 79.157] 6 180.87.105.48 6453 AS6453, US [136.319, 135.882, 136.125] 7 ['*', '*', '*'] 8 129.250.66.9 2914 NTT-LTD-2914, US [135.002, 136.063, 135.87] 9 129.250.5.65 2914 NTT-LTD-2914, US ['*', 137.889, '*'] 10 129.250.4.181 2914 NTT-LTD-2914, US ['*', 454.674, '*'] 11 129.250.7.29 2914 NTT-LTD-2914, US [310.179, 308.374, 306.777] 12 129.250.2.93 2914 NTT-LTD-2914, US [309.439, 307.089, 319.611] 13 128.241.6.227 2914 NTT-LTD-2914, US [289.783, 292.489, 290.928] 14 10.60.17.199 NA NA ['*', 300.945, 300.237] 15 10.60.17.199 NA NA ['*', 300.443, 300.967] 16 160.92.168.33 47957 ING-AS, FR [275.625, 273.625, 273.543] ``` This time, we have the complete route. ## blaeu-resolve The DNS resolver of the probes is used to resolve names into some information: ``` % blaeu-resolve www.afnic.fr [2001:41d0:404:200::2df6] : 5 occurrences Test #78818183 done at 2024-09-06T13:31:11Z ``` As you can see, the default DNS type is AAAA (IP address). But you can ask for others, here, for the mail relay: ``` % blaeu-resolve --type MX proton.me [10 mail.protonmail.ch. 20 mailsec.protonmail.ch.] : 4 occurrences [ERROR: SERVFAIL] : 1 occurrences Test #78818319 done at 2024-09-06T13:40:24Z ``` Because of the frequent presence of a lying DNS resolver, this tool is specially interesting to assess [censorship](https://labs.ripe.net/author/stephane_bortzmeyer/dns-censorship-dns-lies-as-seen-by-ripe-atlas/). Selecting probes by country is therefore common (but [be careful with some countries](https://labs.ripe.net/author/kistel/ethics-of-ripe-atlas-measurements/)): ``` % blaeu-resolve --country FR --type A sci-hub.se [186.2.163.219] : 3 occurrences [127.0.0.1] : 2 occurrences Test #78818372 done at 2024-09-06T13:44:00Z ``` Here, you can that that two probes use a censoring resolver, returning the lie `127.0.0.1`. ## blaeu-cert This connects to a TLS server and gets the certificate. By default, it displays the name ("subject", in X.509 parlance): ``` % blaeu-cert fr.wikipedia.org 5 probes reported [/C=US/ST=California/L=San Francisco/O=Wikimedia Foundation, Inc./CN=*.wikipedia.org] : 5 occurrences Test #78818374 done at 2024-09-06T13:44:15Z ``` By default, it uses port 443 but you can change that, here to get the certificate of a DoT (DNS-over-TLS) public resolver: ``` % blaeu-cert --port 853 dot.bortzmeyer.fr 5 probes reported [/CN=dot.bortzmeyer.fr] : 5 occurrences Test #78818392 done at 2024-09-06T13:46:21Z ``` ## blaeu-ntp You can test public NTP servers: ``` % blaeu-ntp ntp.nic.fr 5 probes reported [Version 4, Mode server, Stratum 2] : 5 occurrences Test #78818431 done at 2024-09-06T13:50:50Z. Mean time offset: 59.143442 s, mean RTT: 0.080120 s ``` ## blaeu-http You can do HTTP requests: ``` % blaeu-http fr-par-as2486.anchors.atlas.ripe.net 5 probes reported Test #78818511 done at 2024-09-06T13:58:12Z Tests: 5 successful tests (100.0 %), 0 errors (0.0 %), 0 timeouts (0.0 %), average RTT: 157 ms, average header size: 131 bytes, average body size: 103 bytes ``` Target *must* be [an anchor](https://atlas.ripe.net/anchors/) (for various reasons, not debated here). You can add a number in the URL, the anchor will return data of this size: ``` % blaeu-http --path /1000 fr-par-as2486.anchors.atlas.ripe.net 3 probes reported Test #78818539 done at 2024-09-06T14:00:31Z Tests: 3 successful tests (100.0 %), 0 errors (0.0 %), 0 timeouts (0.0 %), average RTT: 39 ms, average header size: 131 bytes, average body size: 1106 bytes ``` (The extra bytes are because of the JSON encoding of the answer.) A common workaround to the anchor-only limit is to use `blaeu-cert` to test HTTP reachability since the vast majority of HTTP servers are HTTPS. ## For all commands Remember you can get the complete list of options with `-h`. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1655145487.0 blaeu-2.2/LICENCE0000664000175000017500000000242514251702017013676 0ustar00stephanestephaneCopyright (c) 2017, Stephane Bortzmeyer All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1720361555.0 blaeu-2.2/MANIFEST.in0000664000175000017500000000005714642521123014447 0ustar00stephanestephaneinclude LICENCE include README.md include TAGS ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1740568315.573889 blaeu-2.2/PKG-INFO0000664000175000017500000000631114757573374014033 0ustar00stephanestephaneMetadata-Version: 2.1 Name: blaeu Version: 2.2 Summary: Tools to create (and analyze) RIPE Atlas network measurements Home-page: https://framagit.org/bortzmeyer/blaeu Author: Stéphane Bortzmeyer Author-email: stephane+frama@bortzmeyer.org License: BSD Keywords: networking ripe atlas monitoring ip ping traceroute dig dns Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: System Administrators Classifier: Intended Audience :: Telecommunications Industry Classifier: Topic :: System :: Networking Classifier: License :: OSI Approved :: MIT License Classifier: Programming Language :: Python :: 3 Requires-Python: >=3 Provides-Extra: dev License-File: LICENCE Blaeu, creating measurements on RIPE Atlas probes ================================================= This is a set of `Python `__ programs to start distributed Internet measurements on the network of `RIPE Atlas probes `__, and to analyze their results. For installation, you can use usual Python tools, for instance: :: pip install blaeu (On a Debian machine, the prerequitises are packages python3-pip, python3-openssl, python3-dnspython, and python3-cymruwhois. This is only if you install manually, otherwise pip will install the dependencies.) Usage requires a RIPE Atlas API key (which itself requires a RIPE account), and RIPE Atlas credits. If you don’t have a RIPE account, `register first `__. Once you have an account, `create a key `__, grant it the right to ``schedule a new measurement``, and - use it in environment variable ``ATLASAUTH`` - or put the key in ``~/.atlas/auth`` If you don’t have Atlas credits, host a probe,or become a `LIR `__ or ask a friend. You can then use the six programs (``-h`` will give you a complete list of their options): - ``blaeu-reach target-IP-address`` (test reachability of the target, like ``ping``) - ``blaeu-traceroute target-IP-address`` (like ``traceroute``) - ``blaeu-resolve name`` (use the DNS to resolve the name) - ``blaeu-cert name`` (display the PKIX certificate) - ``blaeu-ntp name`` (test NTP) - ``blaeu-http name`` (test HTTP, only to anchors) You have here `some examples of use `__. You may also be interested by `my article at RIPE Labs `__. Blaeu requires Python 3. Note that `the old version `__ ran on Python 2 but is no longer maintained. (It was `partially documented at RIPE Labs `__.) Name ---- It comes from the `famous Dutch cartographer `__. The logo of the project comes from his “Theatrum Orbis Terrarum” (see `the source `__). Reference site -------------- `On FramaGit `__ Author ------ Stéphane Bortzmeyer stephane+frama@bortzmeyer.org ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725628024.0 blaeu-2.2/README.md0000664000175000017500000000460014666577170014211 0ustar00stephanestephane# Blaeu, creating measurements on RIPE Atlas probes This is a set of [Python](https://www.python.org/) programs to start distributed Internet measurements on the network of [RIPE Atlas probes](https://atlas.ripe.net/), and to analyze their results. For installation, you can use usual Python tools, for instance: ``` pip install blaeu ``` (On a Debian machine, the prerequitises are packages python3-pip, python3-openssl, python3-dnspython, and python3-cymruwhois. This is only if you install manually, otherwise pip will install the dependencies.) Usage requires a RIPE Atlas API key (which itself requires a RIPE account), and RIPE Atlas credits. If you don't have a RIPE account, [register first](https://access.ripe.net/). Once you have an account, [create a key](https://atlas.ripe.net/keys/), grant it the right to `schedule a new measurement`, and * use it in environment variable `ATLASAUTH` * or put the key in `~/.atlas/auth` If you don't have Atlas credits, host a probe,or become a [LIR](https://www.ripe.net/participate/member-support) or ask a friend. You can then use the six programs (`-h` will give you a complete list of their options): * `blaeu-reach target-IP-address` (test reachability of the target, like `ping`) * `blaeu-traceroute target-IP-address` (like `traceroute`) * `blaeu-resolve name` (use the DNS to resolve the name) * `blaeu-cert name` (display the PKIX certificate) * `blaeu-ntp name` (test NTP) * `blaeu-http name` (test HTTP, only to anchors) You have here [some examples of use](EXAMPLES.md). You may also be interested by [my article at RIPE Labs](https://labs.ripe.net/Members/stephane_bortzmeyer/creating-ripe-atlas-one-off-measurements-with-blaeu). Blaeu requires Python 3. Note that [the old version](https://github.com/RIPE-Atlas-Community/ripe-atlas-community-contrib) ran on Python 2 but is no longer maintained. (It was [partially documented at RIPE Labs](https://labs.ripe.net/Members/stephane_bortzmeyer/using-ripe-atlas-to-debug-network-connectivity-problems).) ## Name It comes from the [famous Dutch cartographer](https://en.wikipedia.org/wiki/Willem_Blaeu). The logo of the project comes from his "Theatrum Orbis Terrarum" (see [the source](https://commons.wikimedia.org/wiki/File:Blaeu_1645_-_Livonia_vulgo_Lyefland.jpg)). ## Reference site [On FramaGit](https://framagit.org/bortzmeyer/blaeu) ## Author Stéphane Bortzmeyer ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725700166.0 blaeu-2.2/README.rst0000664000175000017500000000501514667014106014404 0ustar00stephanestephaneBlaeu, creating measurements on RIPE Atlas probes ================================================= This is a set of `Python `__ programs to start distributed Internet measurements on the network of `RIPE Atlas probes `__, and to analyze their results. For installation, you can use usual Python tools, for instance: :: pip install blaeu (On a Debian machine, the prerequitises are packages python3-pip, python3-openssl, python3-dnspython, and python3-cymruwhois. This is only if you install manually, otherwise pip will install the dependencies.) Usage requires a RIPE Atlas API key (which itself requires a RIPE account), and RIPE Atlas credits. If you don’t have a RIPE account, `register first `__. Once you have an account, `create a key `__, grant it the right to ``schedule a new measurement``, and - use it in environment variable ``ATLASAUTH`` - or put the key in ``~/.atlas/auth`` If you don’t have Atlas credits, host a probe,or become a `LIR `__ or ask a friend. You can then use the six programs (``-h`` will give you a complete list of their options): - ``blaeu-reach target-IP-address`` (test reachability of the target, like ``ping``) - ``blaeu-traceroute target-IP-address`` (like ``traceroute``) - ``blaeu-resolve name`` (use the DNS to resolve the name) - ``blaeu-cert name`` (display the PKIX certificate) - ``blaeu-ntp name`` (test NTP) - ``blaeu-http name`` (test HTTP, only to anchors) You have here `some examples of use `__. You may also be interested by `my article at RIPE Labs `__. Blaeu requires Python 3. Note that `the old version `__ ran on Python 2 but is no longer maintained. (It was `partially documented at RIPE Labs `__.) Name ---- It comes from the `famous Dutch cartographer `__. The logo of the project comes from his “Theatrum Orbis Terrarum” (see `the source `__). Reference site -------------- `On FramaGit `__ Author ------ Stéphane Bortzmeyer stephane+frama@bortzmeyer.org ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1720118626.0 blaeu-2.2/TAGS0000664000175000017500000000100014641566542013374 0ustar00stephanestephaneSome useful tags to use with --include and --exclude: Stability tags (documented in ): system-ipv6-stable-1d, system-ipv6-stable-30d, system-ipv6-stable-90d (and the equivalent for IPv4). "nat" for probes behind a network address translator. It is an user tag so often forgotten. Better to use system tags system-ipv4-rfc1918 and system-ipv6-ula. TODO: using the list of all probes, list all the tags and see their number. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1720021005.0 blaeu-2.2/blaeu-cert0000775000175000017500000002031614641270015014662 0ustar00stephanestephane#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Python code to start a RIPE Atlas UDM (User-Defined Measurement). This one is to test X.509/PKIX certificates in TLS servers. You'll need an API key in ~/.atlas/auth. After launching the measurement, it downloads the results and analyzes them, displaying the name ("subject" in X.509 parlance) or issuer. Stéphane Bortzmeyer """ import json import time import os import string import sys import time import socket import collections import copy import Blaeu from Blaeu import Host_Type # https://github.com/pyca/pyopenssl https://pyopenssl.readthedocs.org/en/stable/ import OpenSSL.crypto import cryptography config = Blaeu.Config() # Default values config.display = "n" #Name config.sni = True config.hostname = None config.entire_chain = False # Override what's in the Blaeu package config.port = 443 X509DATETIME = "%Y%m%d%H%M%SZ" RFC3339DATETIME = "%Y-%m-%dT%H:%M:%SZ" class Set(): def __init__(self): self.total = 0 def usage(msg=None): print("Usage: %s target-name-or-IP" % sys.argv[0], file=sys.stderr) config.usage(msg) print("""Also: --issuer or -I : displays the issuer (default is to display the name) --key or -k : displays the public key (default is to display the name) --serial or -S : displays the serial number (default is to display the name) --expiration or -E : displays the expiration datetime (default is to display the name) --entire-chain : displays all the certificates (default is to display the first) --hostname : hostname to send for SNI (default is to use the target) --no-sni : do not send the SNI (Server Name Indication) (default is to send it)""", file=sys.stderr) def format_name(n): result = "" components = n.get_components() for (k, v) in components: result += "/%s=%s" % (k.decode(), v.decode()) return result def specificParse(config, option, value): result = True if option == "--issuer" or option == "-I": config.display = "i" elif option == "--key" or option == "-k": config.display = "k" elif option == "--serial" or option == "-S": config.display = "s" elif option == "--expiration" or option == "-E": config.display = "e" elif option == "--entire-chain": config.entire_chain = True elif option == "--hostname": config.hostname = value elif option == "--no-sni": config.sni = False else: result = False return result (args, data) = config.parse("IkSE", ["issuer", "serial", "expiration", "key", "entire-chain", "hostname=", "no-sni"], specificParse, usage) if len(args) != 1: usage("Not the good number of arguments") sys.exit(1) target = args[0] if config.measurement_id is None: data["definitions"][0]["target"] = target data["definitions"][0]["type"] = "sslcert" data["definitions"][0]["description"] = "X.509 cert of %s" % target del data["definitions"][0]["size"] # Meaningless argument target_type = Blaeu.host_type(target) # RFC 6066 say that we cannot accept a literal IP # address as hostname. if target_type != Host_Type.Name and config.hostname is None and config.sni: usage("If the target is an IP address, we need --hostname (or --no-sni)") sys.exit(1) if target_type == Host_Type.IPv6: config.ipv4 = False af = 6 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv6-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv6-works",] elif target_type == Host_Type.IPv4: config.ipv4 = True af = 4 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv4-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv4-works",] else: # Hostname if config.ipv4: af = 4 else: af = 6 data["definitions"][0]['af'] = af if config.sni: # See above about RFC 6066 if config.hostname is not None: # Even if the target is a host # name, we honor --hostname. data["definitions"][0]['hostname'] = config.hostname else: data["definitions"][0]['hostname'] = target if config.verbose: print(data) try: measurement = Blaeu.Measurement(data) except Blaeu.RequestSubmissionError as error: print(Blaeu.format_error(error), file=sys.stderr) sys.exit(1) if config.verbose: print("Measurement #%s to %s uses %i probes" % (measurement.id, target, measurement.num_probes)) rdata = measurement.results(wait=True, percentage_required=config.percentage_required) else: measurement = Blaeu.Measurement(data=None, id=config.measurement_id) rdata = measurement.results(wait=False) sets = collections.defaultdict(Set) if config.display_probe_asns: config.display_probes = True if config.display_probes: probes_sets = collections.defaultdict(Set) print(("%s probes reported" % len(rdata))) for result in rdata: if config.display_probes: probe_id = result["prb_id"] if config.display_probe_asns: details = Blaeu.ProbeCache.cache_probe_id(config.cache_probes, probe_id) \ if config.cache_probes else Blaeu.Probe(probe_id) asn = getattr(details, "asn_v%i" % (4 if config.ipv4 else 6), None) if 'cert' in result: value = [] if config.entire_chain: num = len(result['cert']) else: num = 1 for i in range(0, num): x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, str(result['cert'][i])) detail = "" content = format_name(x509.get_subject()) if config.display == "i": content += format_name(x509.get_issuer()) elif config.display == "k": key = x509.get_pubkey() content = "%s, type %s, %s bits" % \ (key.to_cryptography_key().public_bytes(cryptography.hazmat.primitives.serialization.Encoding.PEM, cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo).decode().replace("\n", " ").replace("-----BEGIN PUBLIC KEY----- ", "")[:80] + "...", key.type(), key.bits()) elif config.display == "s": content = format(x509.get_serial_number(), '05x') elif config.display == "e": if x509.has_expired(): detail = " (EXPIRED)" t = time.strptime(x509.get_notAfter().decode(), X509DATETIME) content = "%s%s" % (time.strftime(RFC3339DATETIME, t), detail) value.append("%s%s" % (content, detail)) value = "; ".join(value) else: if 'err' in result: error = result['err'] elif 'alert' in result: error = result['alert'] else: error = "UNKNOWN ERROR" value = "FAILED TO GET A CERT: %s" % error sets[value].total += 1 if config.display_probes: if config.display_probe_asns: info = [probe_id, asn] else: info = probe_id if value in probes_sets: probes_sets[value].append(info) else: probes_sets[value] = [info,] sets_data = sorted(sets, key=lambda s: sets[s].total, reverse=False) for myset in sets_data: detail = "" if config.display_probes: detail = "(probes %s)" % probes_sets[myset] print("[%s] : %i occurrences %s" % (myset, sets[myset].total, detail)) print(("Test #%s done at %s" % (measurement.id, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719512755.0 blaeu-2.2/blaeu-http0000775000175000017500000002705614637327263014731 0ustar00stephanestephane#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Python code to start a RIPE Atlas UDM (User-Defined Measurement). This one is for running HTTP queries (with many limitations, because of RIPE Atlas rules, which are in place to prevent abuses. Only RIPE anchors, such as fr-par-as2486.anchors.atlas.ripe.net, may be targeted). You'll need an API key in ~/.atlas/auth. After launching the measurement, it downloads the results and analyzes them. Stéphane Bortzmeyer """ import json import time import os import sys import time import socket import copy import collections import Blaeu config = Blaeu.Config() # Default values config.method = "GET" config.path = "/" config.query = None config.https = False config.timing = False class Set(): def __init__(self): self.failed = True def usage(msg=None): print("Usage: %s target-anchor-name ..." % sys.argv[0], file=sys.stderr) config.usage(msg) # (Poor) documentation for the queries in # https://atlas.ripe.net/docs/apis/rest-api-reference/ # https://atlas.ripe.net/docs/apis/rest-api-manual/measurements/types/type_specific_attributes.html # For the anchors: # https://atlas.ripe.net/docs/howtos/anchors.html#ripe-atlas-anchor-services print("""Also: --method=S : HTTP method to use (default is %s) --path=S : Path in the URL (the anchors accept a number, which will be the size returned) --query=S : Query string to use, in format key=value (ignored by the anchors, may be useful to disable a cache) --https : Uses HTTPS (default is plain HTTP) --timing : Displays extra timing information """ % (config.method), file=sys.stderr) # https://atlas.ripe.net/docs/howtos/anchors.html#http-s def specificParse(config, option, value): result = True if option == "--method": if value.upper() not in ["GET", "HEAD", "POST"]: usage("Unknown HTTP method") return False config.method = value.upper() elif option == "--path": config.path = value if not config.path.startswith("/"): config.path = "/%s" % config.path elif option == "--query": config.query = value elif option == "--https": config.https = True elif option == "--timing": config.timing = True else: result = False return result args, data = config.parse("", ["method=", "path=", "query=", "https", "timing"], specificParse, usage) targets = args if len(targets) == 0: usage("No target found") sys.exit(1) if config.verbose and config.machine_readable: usage("Specify verbose *or* machine-readable output") sys.exit(1) if (config.display_probes or config.display_probe_asns) and config.machine_readable: usage("Display probes *or* machine-readable output") sys.exit(1) data["definitions"][0]["type"] = "http" del data["definitions"][0]["port"] del data["definitions"][0]["size"] for target in targets: data["definitions"][0]["target"] = target data["definitions"][0]["method"] = config.method data["definitions"][0]["path"] = config.path if config.query is not None and config.query != "": data["definitions"][0]["query_string"] = config.query data["definitions"][0]["https"] = config.https if config.timing: data["definitions"][0]["extended_timing"] = True # We don't # parse readtiming yet, so we don't use more_extended_timing. data["definitions"][0]["description"] = ("HTTP %s to %s" % (config.method, target)) + data["definitions"][0]["description"] if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) else: data["probes"][0]["tags"]["include"] = [] if config.ipv4: data["probes"][0]["tags"]["include"].append("system-ipv4-works") else: data["probes"][0]["tags"]["include"].append("system-ipv6-works") if config.exclude is not None: data["probes"][0]["tags"]["exclude"] = copy.copy(config.exclude) if config.measurement_id is None: if config.verbose: print(data) try: measurement = Blaeu.Measurement(data) except Blaeu.RequestSubmissionError as error: print(Blaeu.format_error(error), file=sys.stderr) sys.exit(1) if config.old_measurement is None: config.old_measurement = measurement.id if config.verbose: print("Measurement #%s to %s uses %i probes" % (measurement.id, target, measurement.num_probes)) # Retrieve the results rdata = measurement.results(wait=True, percentage_required=config.percentage_required) else: measurement = Blaeu.Measurement(data=None, id=config.measurement_id) rdata = measurement.results(wait=False) if config.verbose: print("%i results from already-done measurement #%s" % (len(rdata), measurement.id)) if len(rdata) == 0: print("Warning: zero results. Measurement not terminated? May be retry later with --measurement-ID=%s ?" % measurement.id, file=sys.stderr) total_rtt = 0 num_rtt = 0 num_error = 0 num_wrongcode = 0 wrongcodes = [] num_timeout = 0 num_tests = 0 total_hsize = 0 total_bsize = 0 total_ttr = 0 total_ttc = 0 total_ttfb = 0 min_ttr = sys.float_info.max min_ttc = sys.float_info.max min_ttfb = sys.float_info.max max_ttr = 0 max_ttc = 0 max_ttfb = 0 if not config.machine_readable and config.measurement_id is None: print(("%s probes reported" % len(rdata))) if config.display_probe_asns: config.display_probes = True if config.display_probes: failed_probes = collections.defaultdict(Set) for result in rdata: # https://atlas.ripe.net/docs/apis/result-format/ https://atlas.ripe.net/docs/apis/result-format/#version-5000 probe_ok = False probe = result["prb_id"] for test in result["result"]: num_tests += 1 if "rt" in test: total_rtt += int(test["rt"]) num_rtt += 1 total_hsize += int(test["hsize"]) total_bsize += int(test["bsize"]) # Note this is the # size of the entire body, not just the "payload" # member. So, it changes with the size of the text # representation of the client's IP address. if test["res"] == 200: probe_ok = True else: num_wrongcode += 1 wrongcodes.append(test["res"]) if config.timing: # TTR (Time To Resolve) is meaningless without # resolve-on-probe. if config.resolve_on_probe: total_ttr += float(test["ttr"]) if test["ttr"] < min_ttr: min_ttr = test["ttr"] if test["ttr"] > max_ttr: max_ttr = test["ttr"] total_ttc += float(test["ttc"]) if test["ttc"] < min_ttc: min_ttc = test["ttc"] if test["ttc"] > max_ttc: max_ttc = test["ttc"] total_ttfb += float(test["ttfb"]) if test["ttfb"] < min_ttfb: min_ttfb = test["ttfb"] if test["ttfb"] > max_ttfb: max_ttfb = test["ttfb"] elif "err" in test: num_error += 1 elif "x" in test: # Actually, HTTP tests never return # "x". We should parse the error message # above and spots the "timeout". num_timeout += 1 else: print(("Result has no field rt, or x or err"), file=sys.stderr) sys.exit(1) if not probe_ok: if config.display_probes: failed_probes[probe].failed = True if config.display_probe_asns: details = Blaeu.ProbeCache.cache_probe_id(config.cache_probes, probe) \ if config.cache_probes else Blaeu.Probe(probe) failed_probes[probe].asn = getattfb(details, "asn_v%i" % (4 if config.ipv4 else 6), None) if not config.machine_readable: print(("Test #%s done at %s" % (measurement.id, time.strftime("%Y-%m-%dT%H:%M:%SZ", measurement.time)))) if num_rtt == 0: if not config.machine_readable: print("No successful test") else: if not config.machine_readable: wrongstatus = "" if num_wrongcode > 0: wrongstatus = ", %i wrong HTTP status (%.1f %%) %s" % \ (num_wrongcode, num_wrongcode*100.0/num_tests, wrongcodes) print(("Tests: %i successful tests (%.1f %%), %i errors (%.1f %%)%s, %i timeouts (%.1f %%), average RTT: %i ms, average header size: %i bytes, average body size: %i bytes" % \ (num_rtt, num_rtt*100.0/num_tests, num_error, num_error*100.0/num_tests, wrongstatus, num_timeout, num_timeout*100.0/num_tests, total_rtt/num_rtt, total_hsize/num_rtt, total_bsize/num_rtt))) # HTTP errors, for instance 404 by a stupid firewall in-between # may seriously skew the mean sizes. Allow to exclude these # errors? Display both with and without the erroneous responses? # Display the median, not only the average? if len(targets) > 1 and not config.machine_readable: print("") if config.display_probes: if config.display_probe_asns: l = [[probe, failed_probes[probe].asn] for probe in failed_probes.keys()] else: l = failed_probes.keys() all = list(l) if all != []: print(all) if config.timing: if not config.machine_readable: resolve_text = "" connect_text = "M" if config.resolve_on_probe: resolve_text = "Mean time to resolve: %.3f ms" % (total_ttr/num_rtt) connect_text = ", m" print("%s%sean time to connect: %.3f ms, mean time to first byte: %.3f ms" % \ (resolve_text, connect_text, total_ttc/num_rtt, total_ttfb/num_rtt)) resolve_text = "" connect_text = "M" if config.resolve_on_probe: resolve_text = "Minimum time to resolve: %.3f ms, maximum time to resolve: %.3f ms " % \ (min_ttr, max_ttr) connect_text = ", m" print("%s%sinimum time to connect: %.3f ms, maximum time to connect: %.3f ms, Minimum time to first byte: %.3f ms, maximum time to first byte: %.3f ms" % \ (resolve_text, connect_text, min_ttc, max_ttc, min_ttfb, max_ttfb)) if config.machine_readable: if num_rtt != 0: percent_rtt = total_rtt/num_rtt else: percent_rtt = 0 print(",".join([target, str(measurement.id), "%s/%s" % (len(rdata),measurement.num_probes), \ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "%i" % num_rtt, \ "%.1f" % (num_rtt*100.0/num_tests), "%i" % num_error, "%.1f" % (num_error*100.0/num_tests), \ "%i" % num_timeout, "%.1f" % (num_timeout*100.0/num_tests), "%i" % (percent_rtt)])) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725631862.0 blaeu-2.2/blaeu-ntp0000775000175000017500000001401014666606566014545 0ustar00stephanestephane#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Python code to start a RIPE Atlas UDM (User-Defined Measurement). This one is to test NTP (time) servers. You'll need an API key in ~/.atlas/auth. After launching the measurement, it downloads the results and analyzes. By default, it displays only version, mode and stratum, but you can request a full display. Stéphane Bortzmeyer """ import json import time import os import string import sys import time import socket import collections import copy import Blaeu from Blaeu import Host_Type config = Blaeu.Config() # Default values config.only_stratum = False config.display_all = False class Set(): def __init__(self): self.total = 0 def usage(msg=None): print("Usage: %s target-name-or-IP" % sys.argv[0], file=sys.stderr) config.usage(msg) print("""Also: --only-stratum : displays only the stratum --display-all : displays all the details""", file=sys.stderr) def specificParse(config, option, value): result = True if option == "--only-stratum": config.only_stratum = True elif option == "--display-all": config.display_all = True else: result = False return result (args, data) = config.parse("", ["only-stratum", "display-all"], specificParse, usage) if config.only_stratum and config.display_all: usage("--only-stratum and --display-all are not compatible") sys.exit(1) if len(args) != 1: usage("Not the good number of arguments") sys.exit(1) target = args[0] if config.measurement_id is None: data["definitions"][0]["target"] = target data["definitions"][0]["type"] = "ntp" data["definitions"][0]["description"] = "NTP measurement of %s" % target target_type = Blaeu.host_type(target) if target_type == Host_Type.IPv6: config.ipv4 = False af = 6 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv6-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv6-works",] elif target_type == Host_Type.IPv4: config.ipv4 = True af = 4 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv4-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv4-works",] else: # Hostname if config.ipv4: af = 4 else: af = 6 data["definitions"][0]['af'] = af if config.verbose: print(data) try: measurement = Blaeu.Measurement(data) except Blaeu.RequestSubmissionError as error: print(Blaeu.format_error(error), file=sys.stderr) sys.exit(1) if config.verbose: print("Measurement #%s to %s uses %i probes" % (measurement.id, target, measurement.num_probes)) rdata = measurement.results(wait=True, percentage_required=config.percentage_required) else: measurement = Blaeu.Measurement(data=None, id=config.measurement_id) rdata = measurement.results(wait=False) sets = collections.defaultdict(Set) if config.display_probe_asns: config.display_probes = True if config.display_probes: probes_sets = collections.defaultdict(Set) print(("%s probes reported" % len(rdata))) min_offset = 0 max_offset = sys.float_info.max total_offset = 0 min_rtt = 0 max_rtt = sys.float_info.max total_rtt = 0 successes = 0 # Documentation at https://atlas.ripe.net/docs/apis/result-format/#version-5000 for result in rdata: if config.display_probes: probe_id = result["prb_id"] if config.display_probe_asns: details = Blaeu.ProbeCache.cache_probe_id(config.cache_probes, probe_id) \ if config.cache_probes else Blaeu.Probe(probe_id) asn = getattr(details, "asn_v%i" % (4 if config.ipv4 else 6), None) if 'stratum' in result: offset = 0 num = 0 for m in result['result']: if 'offset' in m: successes += 1 total_offset += float(m['offset']) total_rtt += float(m['rtt']) if config.only_stratum: value = "Stratum %s" % result['stratum'] elif config.display_all: value = "Version %s, Mode %s, Stratum %s, Precision %s s, Root delay %s s, Root dispersion %s s" % \ (result['version'], result['mode'], result['stratum'], result['precision'], \ result['root-delay'], result['root-dispersion']) else: value = "Version %s, Mode %s, Stratum %s" % (result['version'], result['mode'], result['stratum']) else: if 'err' in result: error = result['err'] elif 'alert' in result: error = result['alert'] else: error = "UNKNOWN ERROR (timeout?)" value = "FAILED TO GET A RESULT: %s" % error sets[value].total += 1 if config.display_probes: if config.display_probe_asns: info = [probe_id, asn] else: info = probe_id if value in probes_sets: probes_sets[value].append(info) else: probes_sets[value] = [info,] sets_data = sorted(sets, key=lambda s: sets[s].total, reverse=False) for myset in sets_data: detail = "" if config.display_probes: detail = "(probes %s)" % probes_sets[myset] print("[%s] : %i occurrences %s" % (myset, sets[myset].total, detail)) if successes > 0: means = "Mean time offset: %.6f s, mean RTT: %6f s" % \ (total_offset/successes, total_rtt/successes) else: means = "" print("Test #%s done at %s. %s" % \ (measurement.id, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), means)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719406327.0 blaeu-2.2/blaeu-reach0000775000175000017500000001770314637007367015032 0ustar00stephanestephane#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Python code to start a RIPE Atlas UDM (User-Defined Measurement). This one is for running IPv4 or IPv6 ICMP queries to test reachability. You'll need an API key in ~/.atlas/auth. After launching the measurement, it downloads the results and analyzes them. Stéphane Bortzmeyer """ import json import time import os import sys import time import socket import copy import collections import Blaeu config = Blaeu.Config() # Default values config.tests = 3 # ICMP packets per probe config.by_probe = False # Default is to count by test, not by probe config.display_probes = False class Set(): def __init__(self): self.failed = True def is_ip_address(str): try: addr = socket.inet_pton(socket.AF_INET6, str) except socket.error: # not a valid IPv6 address try: addr = socket.inet_pton(socket.AF_INET, str) except socket.error: # not a valid IPv4 address either return False return True def usage(msg=None): print("Usage: %s target-IP-address ..." % sys.argv[0], file=sys.stderr) config.usage(msg) print("""Also: --tests=N or -d N : send N ICMP packets from each probe (default is %s) --by_probe : count the percentage of success by probe, not by test (useless if --tests=1) """ % (config.tests), file=sys.stderr) def specificParse(config, option, value): result = True if option == "--tests" or option == "-d": config.tests = int(value) elif option == "--by_probe": config.by_probe = True else: result = False return result args, data = config.parse("d:", ["by_probe", "tests="], specificParse, usage) targets = args if len(targets) == 0: usage("No target found") sys.exit(1) if config.verbose and config.machine_readable: usage("Specify verbose *or* machine-readable output") sys.exit(1) if (config.display_probes or config.display_probe_asns) and config.machine_readable: usage("Display probes *or* machine-readable output") sys.exit(1) data["definitions"][0]["type"] = "ping" del data["definitions"][0]["port"] data["definitions"][0]["packets"] = config.tests for target in targets: if not is_ip_address(target): print(("Target must be an IP address, NOT AN HOST NAME"), file=sys.stderr) sys.exit(1) data["definitions"][0]["target"] = target data["definitions"][0]["description"] = ("Ping %s" % target) + data["definitions"][0]["description"] if target.find(':') > -1: config.ipv4 = False data["definitions"][0]['af'] = 6 else: config.ipv4 = True data["definitions"][0]['af'] = 4 # Yes, it was already done in parse() but we have to do it again now that we # know the address family of the target. See bug #9. Note that we silently # override a possible explicit choice of the user (her -4 may be ignored). if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) else: data["probes"][0]["tags"]["include"] = [] if config.ipv4: data["probes"][0]["tags"]["include"].append("system-ipv4-works") # Some probes cannot do ICMP outgoing (firewall?) else: data["probes"][0]["tags"]["include"].append("system-ipv6-works") if config.exclude is not None: data["probes"][0]["tags"]["exclude"] = copy.copy(config.exclude) if config.measurement_id is None: if config.verbose: print(data) try: measurement = Blaeu.Measurement(data) except Blaeu.RequestSubmissionError as error: print(Blaeu.format_error(error), file=sys.stderr) sys.exit(1) if config.old_measurement is None: config.old_measurement = measurement.id if config.verbose: print("Measurement #%s to %s uses %i probes" % (measurement.id, target, measurement.num_probes)) # Retrieve the results rdata = measurement.results(wait=True, percentage_required=config.percentage_required) else: measurement = Blaeu.Measurement(data=None, id=config.measurement_id) rdata = measurement.results(wait=False) if config.verbose: print("%i results from already-done measurement #%s" % (len(rdata), measurement.id)) if len(rdata) == 0: print("Warning: zero results. Measurement not terminated? May be retry later with --measurement-ID=%s ?" % measurement.id, file=sys.stderr) total_rtt = 0 num_rtt = 0 num_error = 0 num_timeout = 0 num_tests = 0 if config.by_probe: probes_success = 0 probes_failure = 0 num_probes = 0 if not config.machine_readable and config.measurement_id is None: print(("%s probes reported" % len(rdata))) if config.display_probe_asns: config.display_probes = True if config.display_probes: failed_probes = collections.defaultdict(Set) for result in rdata: probe_ok = False probe = result["prb_id"] if config.by_probe: num_probes += 1 for test in result["result"]: num_tests += 1 if "rtt" in test: total_rtt += int(test["rtt"]) num_rtt += 1 probe_ok = True elif "error" in test: num_error += 1 elif "x" in test: num_timeout += 1 else: print(("Result has no field rtt, or x or error"), file=sys.stderr) sys.exit(1) if config.by_probe: if probe_ok: probes_success += 1 else: probes_failure += 1 if not probe_ok: if config.display_probes: failed_probes[probe].failed = True if config.display_probe_asns: details = Blaeu.ProbeCache.cache_probe_id(config.cache_probes, probe) \ if config.cache_probes else Blaeu.Probe(probe) failed_probes[probe].asn = getattr(details, "asn_v%i" % (4 if config.ipv4 else 6), None) if not config.machine_readable: print(("Test #%s done at %s" % (measurement.id, time.strftime("%Y-%m-%dT%H:%M:%SZ", measurement.time)))) if num_rtt == 0: if not config.machine_readable: print("No successful test") else: if not config.machine_readable: if not config.by_probe: print(("Tests: %i successful tests (%.1f %%), %i errors (%.1f %%), %i timeouts (%.1f %%), average RTT: %i ms" % \ (num_rtt, num_rtt*100.0/num_tests, num_error, num_error*100.0/num_tests, num_timeout, num_timeout*100.0/num_tests, total_rtt/num_rtt))) else: print(("Tests: %i successful probes (%.1f %%), %i failed (%.1f %%), average RTT: %i ms" % \ (probes_success, probes_success*100.0/num_probes, probes_failure, probes_failure*100.0/num_probes, total_rtt/num_rtt))) if len(targets) > 1 and not config.machine_readable: print("") if config.display_probes: if config.display_probe_asns: l = [[probe, failed_probes[probe].asn] for probe in failed_probes.keys()] else: l = failed_probes.keys() all = list(l) if all != []: print(all) if config.machine_readable: if num_rtt != 0: percent_rtt = total_rtt/num_rtt else: percent_rtt = 0 print(",".join([target, str(measurement.id), "%s/%s" % (len(rdata),measurement.num_probes), \ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "%i" % num_rtt, \ "%.1f" % (num_rtt*100.0/num_tests), "%i" % num_error, "%.1f" % (num_error*100.0/num_tests), \ "%i" % num_timeout, "%.1f" % (num_timeout*100.0/num_tests), "%i" % (percent_rtt)])) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1720000579.0 blaeu-2.2/blaeu-resolve0000775000175000017500000005320014641220103015373 0ustar00stephanestephane#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Python code to start a RIPE Atlas UDM (User-Defined Measurement). This one is for running DNS to resolve a name from many places, in order to survey local cache poisonings, effect of hijackings and other DNS rejuvenation effects. You'll need an API key in ~/.atlas/auth. After launching the measurement, it downloads the results and analyzes them. Stéphane Bortzmeyer """ import json import sys import time import base64 import copy import collections # DNS Python http://www.dnspython.org/ import dns.message import Blaeu from Blaeu import Host_Type config = Blaeu.Config() # Default values config.qtype = 'AAAA' config.qclass = "IN" config.display_resolvers = False config.display_rtt = False config.display_validation = False config.edns_size = None config.dnssec = False config.dnssec_checking = True config.nameserver = None config.recursive = True config.sort = False config.nsid = False config.ede = False config.only_one_per_probe = True config.protocol = "UDP" config.tls = False config.probe_id = False config.answer_section = True config.authority_section = False config.additional_section = False # Local values edns_size = None # Constants MAXLEN = 80 # Maximum length of a displayed resource record class Set(): def __init__(self): self.total = 0 self.successes = 0 self.rtt = 0 def usage(msg=None): print("Usage: %s domain-name" % sys.argv[0], file=sys.stderr) config.usage(msg) print("""Also: --displayresolvers or -l : display the resolvers IP addresses (WARNING: big lists) --norecursive or -Z : asks the resolver to NOT recurse (default is to recurse, note --norecursive works ONLY if asking a specific resolver, not with the default one) --dnssec or -D : asks the resolver the DNSSEC records --nsid : asks the resolver with NSID (name server identification) --ede : displays EDE (Extended DNS Errors) --ednssize=N or -B N : asks for EDNS with the "payload size" option (default is very old DNS, without EDNS) --tcp: uses TCP (default is UDP) --tls: uses TLS (implies TCP) --checkingdisabled or -k : asks the resolver to NOT perform DNSSEC validation --displayvalidation or -j : displays the DNSSEC validation status --displayrtt : displays the average RTT --authority : displays the Authority section of the answer --additional : displays the Additional section of the answer --sort or -S : sort the result sets --type or -q : query type (default is %s) --class : query class (default is %s) --severalperprobe : count all the resolvers of each probe (default is to count only the first to reply) --nameserver=name_or_IPaddr[,...] or -x name_or_IPaddr : query this name server (default is to query the probe's resolver) --probe_id : prepend probe ID (and timestamp) to the domain name (default is to abstain) """ % (config.qtype, config.qclass), file=sys.stderr) def specificParse(config, option, value): result = True if option == "--type" or option == "-q": config.qtype = value elif option == "--class": # For Chaos, use "CHAOS", not "CH" config.qclass = value elif option == "--norecursive" or option == "-Z": config.recursive = False elif option == "--dnssec" or option == "-D": config.dnssec = True elif option == "--nsid": config.nsid = True elif option == "--ede": config.ede = True elif option == "--probe_id": config.probe_id = True elif option == "--ednssize" or option == "-B": config.edns_size = int(value) elif option == "--tcp": config.protocol = "TCP" elif option == "--tls": config.tls = True elif option == "--checkingdisabled" or option == "-k": config.dnssec_checking = False elif option == "--sort" or option == "-S": config.sort = True elif option == "--authority": config.answer_section = False config.authority_section = True elif option == "--additional": config.answer_section = False config.additional_section = True elif option == "--nameserver" or option == "-x": config.nameserver = value config.nameservers = config.nameserver.split(",") elif option == "--displayresolvers" or option == "-l": config.display_resolvers = True elif option == "--displayvalidation" or option == "-j": config.display_validation = True elif option == "--displayrtt": config.display_rtt = True elif option == "--severalperprobe": config.only_one_per_probe = False else: result = False return result args, data = config.parse("q:ZDkSx:ljB:", ["type=", "class=", "ednssize=", "displayresolvers", "probe_id", "displayrtt", "displayvalidation", "dnssec", "nsid", "ede", "norecursive", "authority", "additional", "tcp", "tls", "checkingdisabled", "nameserver=", "sort", "severalperprobe"], specificParse, usage) if len(args) != 1: usage() sys.exit(1) domainname = args[0] if config.tls: config.protocol = "TCP" # We don't set the port (853) but Atlas does it for us data["definitions"][0]["type"] = "dns" del data["definitions"][0]["size"] del data["definitions"][0]["port"] data["definitions"][0]["query_argument"] = domainname data["definitions"][0]["description"] = ("DNS resolution of %s/%s" % (domainname, config.qtype)) + data["definitions"][0]["description"] data["definitions"][0]["query_class"] = config.qclass data["definitions"][0]["query_type"] = config.qtype if config.edns_size is not None and config.protocol == "UDP": data["definitions"][0]["udp_payload_size"] = config.edns_size edns_size = config.edns_size if config.dnssec or config.display_validation: # https://atlas.ripe.net/docs/api/v2/reference/#!/measurements/Dns_Type_Measurement_List_POST data["definitions"][0]["set_do_bit"] = True if config.edns_size is None and config.protocol == "UDP": edns_size = 4096 if config.nsid: data["definitions"][0]["set_nsid_bit"] = True if config.edns_size is None and config.protocol == "UDP": edns_size = 1024 if config.ede: if config.edns_size is None and config.protocol == "UDP": edns_size = 1024 if edns_size is not None and config.protocol == "UDP": data["definitions"][0]["udp_payload_size"] = edns_size if not config.dnssec_checking: data["definitions"][0]["set_cd_bit"] = True if config.recursive: data["definitions"][0]["set_rd_bit"] = True else: data["definitions"][0]["set_rd_bit"] = False if config.tls: data["definitions"][0]["tls"] = True if config.probe_id: data["definitions"][0]["prepend_probe_id"] = True data["definitions"][0]["protocol"] = config.protocol if config.verbose and config.machine_readable: usage("Specify verbose *or* machine-readable output") sys.exit(1) if (config.display_probes or config.display_probe_asns or config.display_resolvers or config.display_rtt) and config.machine_readable: usage("Display probes/probeasns/resolvers/RTT *or* machine-readable output") sys.exit(1) if config.nameserver is None: config.nameservers = [None,] description = data["definitions"][0]["description"] for nameserver in config.nameservers: if nameserver is None: data["definitions"][0]["use_probe_resolver"] = True # Exclude probes which do not have at least one working resolver data["probes"][0]["tags"]["include"].append("system-resolves-a-correctly") data["probes"][0]["tags"]["include"].append("system-resolves-aaaa-correctly") else: data["definitions"][0]["use_probe_resolver"] = False data["definitions"][0]["target"] = nameserver serveraddr_type = Blaeu.host_type(nameserver) data["definitions"][0]["description"] = description + (" via nameserver %s" % nameserver) if serveraddr_type == Host_Type.IPv6: config.ipv4 = False data["definitions"][0]['af'] = 6 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv6-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv6-works",] elif serveraddr_type == Host_Type.IPv4: config.ipv4 = True data["definitions"][0]['af'] = 4 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv4-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv4-works",] else: # Probably an host name pass if config.measurement_id is None: if config.verbose: print(data) try: measurement = Blaeu.Measurement(data, lambda delay: sys.stderr.write( "Sleeping %i seconds...\n" % delay)) except Blaeu.RequestSubmissionError as error: print(Blaeu.format_error(error), file=sys.stderr) sys.exit(1) if not config.machine_readable and config.verbose: print("Measurement #%s for %s/%s uses %i probes" % \ (measurement.id, domainname, config.qtype, measurement.num_probes)) old_measurement = measurement.id results = measurement.results(wait=True) else: measurement = Blaeu.Measurement(data=None, id=config.measurement_id) results = measurement.results(wait=False) if config.verbose: print("%i results from already-done measurement %s" % (len(results), measurement.id)) if len(results) == 0: print("Warning: zero results. Measurement not terminated? May be retry later with --measurement-ID=%s ?" % (measurement.id), file=sys.stderr) probes = 0 successes = 0 qtype_num = dns.rdatatype.from_text(config.qtype) # Raises dns.rdatatype.UnknownRdatatype if unknown sets = collections.defaultdict(Set) if config.display_probes: probes_sets = collections.defaultdict(set) if config.display_probe_asns: probe_asns_sets = collections.defaultdict(set) if config.display_resolvers: resolvers_sets = collections.defaultdict(set) for result in results: probes += 1 probe_id = result["prb_id"] if config.display_probe_asns: _probe = Blaeu.ProbeCache.cache_probe_id(config.cache_probes, probe_id) if config.cache_probes else Blaeu.Probe(probe_id) first_error = "" probe_resolves = False resolver_responds = False all_timeout = True if "result" in result: result_set = [{'result': result['result']},] elif "resultset" in result: result_set = result['resultset'] elif "error" in result: result_set = [] myset = [] if "timeout" in result['error']: myset.append("TIMEOUT") elif "socket" in result['error']: all_timeout = False myset.append("NETWORK PROBLEM WITH RESOLVER") elif "TUCONNECT" in result['error']: all_timeout = False myset.append("TUCONNECT (may be a TLS negotiation error or a TCP connection issue)") else: all_timeout = False myset.append("NO RESPONSE FOR UNKNOWN REASON at probe %s" % probe_id) else: raise Blaeu.WrongAssumption("Neither result not resultset member") if len(result_set) == 0: myset.sort() set_str = " ".join(myset) sets[set_str].total += 1 if config.display_probes: probes_sets[set_str].add(probe_id) if config.display_probe_asns: probe_asns_sets[set_str].add(getattr(_probe, "asn_v%i" % (4 if config.ipv4 else 6), None)) for result_i in result_set: try: if "dst_addr" in result_i: resolver = str(result_i['dst_addr']) elif "dst_name" in result_i: # Apparently, used when there was a problem resolver = str(result_i['dst_name']) elif "dst_addr" in result: # Used when specifying a name server resolver = str(result['dst_addr']) elif "dst_name" in result: # Apparently, used when there was a problem resolver = str(result['dst_name']) else: resolver = "UNKNOWN RESOLUTION ERROR" myset = [] if "result" not in result_i: if config.only_one_per_probe: continue else: if "timeout" in result_i['error']: myset.append("TIMEOUT") elif "socket" in result_i['error']: all_timeout = False myset.append("NETWORK PROBLEM WITH RESOLVER") else: all_timeout = False myset.append("NO RESPONSE FOR UNKNOWN REASON at probe %s" % probe_id) else: all_timeout = False resolver_responds = True answer = result_i['result']['abuf'] + "==" content = base64.b64decode(answer) msg = dns.message.from_wire(content) if config.ede: if hasattr(dns.edns, 'EDE'): # Appeared in DNS Python in 2024? opt_ede = next((opt for opt in msg.options if opt.otype == dns.edns.EDE), None) if opt_ede: ede = opt_ede.to_text() myset.append(ede) if config.nsid: opt_nsid = next((opt for opt in msg.options if opt.otype == dns.edns.NSID), None) # dnspython handles NSID with version >= # 2.6. Before that, we have to do it. if opt_nsid and hasattr(opt_nsid, "data"): # < 2.6 nsid = opt_nsid.data.decode() elif opt_nsid and hasattr(opt_nsid, "nsid"): # > 2.6 nsid = opt_nsid.to_text() if nsid.startswith("NSID "): nsid = nsid[5:] # Remove the "NSID " prefix else: # NSID option not found or could not be parsed nsid = None myset.append("NSID: %s;" % nsid) successes += 1 if msg.rcode() == dns.rcode.NOERROR: probe_resolves = True if config.answer_section: if result_i['result']['ANCOUNT'] == 0 and config.verbose: # If we test an authoritative server, and it returns a delegation, we won't see anything... print("Warning: reply at probe %s has no answers: may be the server returned a delegation, or does not have data of type %s? For the first case, you may want to use --authority." % (probe_id, config.qtype), file=sys.stderr) interesting_section = msg.answer elif config.authority_section: interesting_section = msg.authority elif config.additional_section: interesting_section = msg.additional for rrset in interesting_section: for rdata in rrset: if rdata.rdtype == qtype_num: myset.append(str(rdata)[0:MAXLEN].lower()) # We truncate because DNSKEY can be very long if config.display_validation and (msg.flags & dns.flags.AD): myset.append(" (Authentic Data flag) ") if (msg.flags & dns.flags.TC): if edns_size is not None: myset.append(" (TRUNCATED - EDNS buffer size was %d ) " % edns_size) else: myset.append(" (TRUNCATED - May have to use --ednssize) ") else: if msg.rcode() == dns.rcode.REFUSED: # Not SERVFAIL since # it can be legitimate (DNSSEC problem, for instance) if config.only_one_per_probe and len(result_set) > 1: # It # does not handle the case where there # are several resolvers and all say # REFUSED (probably a rare case). if first_error == "": first_error = "ERROR: %s" % dns.rcode.to_text(msg.rcode()) continue # Try again else: probe_resolves = True # NXDOMAIN or SERVFAIL are legitimate myset.append("ERROR: %s" % dns.rcode.to_text(msg.rcode())) myset.sort() # Not ideal since the alphabetical sort # will, for instance, put EDE before # ERROR. We should create myset more # intelligently. set_str = " ".join(myset) sets[set_str].total += 1 if "error" not in result_i: sets[set_str].successes += 1 if config.display_probes: probes_sets[set_str].add(probe_id) if config.display_probe_asns: _res_af = result_i.get("af") or result.get("af") if _res_af: probe_asns_sets[set_str].add(getattr(_probe, "asn_v%i" % _res_af)) if config.display_resolvers: resolvers_sets[set_str].add(resolver) if config.display_rtt: if "error" not in result_i: if "result" not in result_i: sets[set_str].rtt += result_i['rt'] else: sets[set_str].rtt += result_i['result']['rt'] except dns.name.BadLabelType: if not config.machine_readable: print("Probe %s failed (bad label in name)" % probe_id, file=sys.stderr) except dns.message.TrailingJunk: if not config.machine_readable: print("Probe %s failed (trailing junk)" % probe_id, file=sys.stderr) except dns.exception.FormError: if not config.machine_readable: print("Probe %s failed (malformed DNS message)" % probe_id, file=sys.stderr) if config.only_one_per_probe: break if not probe_resolves and first_error != "" and config.verbose: print("Warning, probe %s has no working resolver (first error is \"%s\")" % (probe_id, first_error), file=sys.stderr) if not resolver_responds: if all_timeout and not config.only_one_per_probe: if config.verbose: print("Warning, probe %s never got reply from any resolver" % (probe_id), file=sys.stderr) set_str = "TIMEOUT(S) on all resolvers" sets[set_str].total += 1 else: myset.sort() set_str = " ".join(myset) if config.sort: sets_data = sorted(sets, key=lambda s: sets[s].total, reverse=True) else: sets_data = sets details = [] if not config.machine_readable and config.nameserver is not None: print("Nameserver %s" % nameserver) if not config.answer_section: if config.authority_section: print("Authority section of the DNS responses") elif config.additional_section: print("Additional section of the DNS responses") else: print("INTERNAL PROBLEM: no section to display?") for myset in sets_data: detail = "" if config.display_probes: detail = "(probes %s)" % probes_sets[myset] if config.display_probe_asns: detail = "(probe asns %s)" % probe_asns_sets[myset] if config.display_resolvers: detail += "(resolvers %s)" % resolvers_sets[myset] if config.display_rtt and sets[myset].successes > 0: detail += "Average RTT %i ms" % (sets[myset].rtt/sets[myset].successes) if not config.machine_readable: print("[%s] : %i occurrences %s" % (myset, sets[myset].total, detail)) else: details.append("[%s];%i" % (myset, sets[myset].total)) if not config.machine_readable: print(("Test #%s done at %s" % (measurement.id, time.strftime("%Y-%m-%dT%H:%M:%SZ", measurement.time)))) print("") else: if config.nameserver is None: ns = "DEFAULT RESOLVER" else: ns = config.nameserver print(",".join([domainname, config.qtype, str(measurement.id), "%s/%s" % (len(results), measurement.num_probes), \ time.strftime("%Y-%m-%dT%H:%M:%SZ", measurement.time), ns] + details)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719988292.0 blaeu-2.2/blaeu-traceroute0000775000175000017500000002601314641170104016100 0ustar00stephanestephane#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Python code to start a RIPE Atlas UDM (User-Defined Measurement). This one is for running IPv4 or IPv6 traceroute queries to analyze routing You'll need an API key in ~/.atlas/auth. Stéphane Bortzmeyer """ import json import time import os import string import sys import time import socket import pickle as pickle import copy import Blaeu from Blaeu import Host_Type # If we use --format: # import cymruwhois config = Blaeu.Config() # Default values config.protocol = "UDP" config.format = False config.whois = True # But some networks block outgoing port 43 config.do_lookup = False config.do_reverse_lookup = False config.first_hop = 1 config.max_hops = 32 def lookup_hostname(str): try: info = socket.getaddrinfo(str, 0, socket.AF_UNSPEC, socket.SOCK_STREAM,0, socket.AI_PASSIVE) if len(info) > 1: print("%s returns more then one IP address please select one" % str) count=0 for ip in info: count= count + 1 fa, socktype, proto, canonname, sa = ip print("%s - %s" % (count, sa[0])) selection=int(input("=>")) selection = selection - 1 selected_ip=info[selection][4][0] else: selected_ip=info[0][4][0] print("Using IP: %s" % selected_ip) except socket.error: return False return selected_ip def lookup_ip(ip): try: name, alias, addresslist = socket.gethostbyaddr(ip) except Exception as e: msg = "No PTR" return msg return name def usage(msg=None): print("Usage: %s target-IP-address-or-name" % sys.argv[0], file=sys.stderr) config.usage(msg) print("""Also: --format or -k : downloads the results and format them in a traditional traceroute way --simpleformat : the same, but without looking up the AS (useful if you have no whois access) --protocol=PROTO or -j PROTO : uses this protocol (UDP, TCP or ICMP, default is %s) --do_lookup or -d : Enables IP lookup feature (default is disabled, may become interactive if the machine has several addresses) --do_reverse_lookup or -l : Enables reverse IP lookup feature for hops --first_hop=N or -y N : TTL/max hop count for the first hop (default %d) --max_hops=N or -x N : TTL/max hop count for the last hop (default %d) """ % (config.protocol, config.first_hop, config.max_hops), file=sys.stderr) """For "TCP Ping" , you need --protocol TCP --size=0 --port=$PORT --first_hop=64 """ def specificParse(config, option, value): result = True if option == "--protocol" or option == "-j": if value.upper() != "UDP" and value.upper() != "ICMP" and value.upper() != "TCP": usage("Protocol must be UDP or ICMP or TCP: %s rejected" % value.upper()) sys.exit(1) config.protocol = value.upper() elif option == "--first_hop" or option == "-y": config.first_hop = int(value) elif option == "--max_hops" or option == "-x": config.max_hops = int(value) elif option == "--format" or option == "-k": config.format = True elif option == "--simpleformat": config.format = True config.whois = False elif option == "--do_lookup" or option == "-d": config.do_lookup = True elif option == "--do_reverse_lookup" or option == "-l": config.do_reverse_lookup = True else: result = False return result args, data = config.parse("j:x:kdy:l", ["format", "simpleformat", "protocol=", "first_hop=", "max_hops=", "do_lookup","do_reverse_lookup"], specificParse, usage) if len(args) != 1: usage() sys.exit(1) target = args[0] if config.display_probe_asns: print("Displaying the AS number is always done with the --format option", file=sys.stderr) print("", file=sys.stderr) target_type = Blaeu.host_type(target) if config.do_lookup: if target_type != Host_Type.Name: print(("Ignoring --do-lookup, since \"%s\" is already an IP address" % target), file=sys.stderr) else: hostname = target target = lookup_hostname(hostname) target_type = Blaeu.host_type(target) if not target: print(("Unknown host name \"%s\"" % hostname), file=sys.stderr) sys.exit(1) else: if target_type == Host_Type.Name: print("Target must be an IP address, NOT AN HOST NAME (or use --do_lookup)", file=sys.stderr) sys.exit(1) data["definitions"][0]["description"] = ("Traceroute %s" % target) + data["definitions"][0]["description"] data["definitions"][0]["type"] = "traceroute" data["definitions"][0]["protocol"] = config.protocol data["definitions"][0]["target"] = target if config.first_hop is not None: data["definitions"][0]['first_hop'] = config.first_hop if config.max_hops is not None: data["definitions"][0]['max_hops'] = config.max_hops if target_type == Host_Type.IPv6: config.ipv4 = False af = 6 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv6-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv6-works",] else: config.ipv4 = True af = 4 if config.include is not None: data["probes"][0]["tags"]["include"] = copy.copy(config.include) data["probes"][0]["tags"]["include"].append("system-ipv4-works") else: data["probes"][0]["tags"]["include"] = ["system-ipv4-works",] data["definitions"][0]['af'] = af if config.measurement_id is None: if config.verbose: print(data) try: measurement = Blaeu.Measurement(data) except Blaeu.RequestSubmissionError as error: print(Blaeu.format_error(error), file=sys.stderr) sys.exit(1) print("Measurement #%s %s uses %i probes" % (measurement.id, data["definitions"][0]["description"], measurement.num_probes)) rdata = measurement.results(wait=True, percentage_required=config.percentage_required) print(("%s probes reported" % len(rdata))) else: measurement = Blaeu.Measurement(data=None, id=config.measurement_id) rdata = measurement.results(wait=False) if config.verbose: print("%i results from already-done measurement #%s" % (len(rdata), measurement.id)) print(("Test #%s done at %s" % (measurement.id, time.strftime("%Y-%m-%dT%H:%M:%SZ", measurement.time)))) if config.format: # Code stolen from json2traceroute.py if config.whois: from cymruwhois import Client def whoisrecord(ip): try: currenttime = time.time() ts = currenttime if ip in whois: ASN,ts = whois[ip] else: ts = 0 if ((currenttime - ts) > 36000): c = Client() ASN = c.lookup(ip) whois[ip] = (ASN,currenttime) return ASN except Exception as e: return e if config.whois: try: pkl_file = open('whois.pkl', 'rb') whois = pickle.load(pkl_file) except IOError: whois = {} # Create traceroute output try: for probe in rdata: probefrom = probe["from"] if probefrom: if config.whois: ASN = whoisrecord(probefrom) if not isinstance(ASN, Exception): asn = ASN.asn owner = ASN.owner else: asn = "No AS: %s \"%s\"" % (type(ASN).__name__, ASN) owner = "Unknown" else: asn = "" owner = "" try: print("From: ",probefrom," ",asn," ",owner) except Exception as e: print("From: ", probefrom," ","AS lookup error: ",e) print("Source address: ",probe["src_addr"]) print("Probe ID: ",probe["prb_id"]) result = probe["result"] for proberesult in result: ASN = {} if "result" in proberesult: print(proberesult["hop"]," ", end=' ') hopresult = proberesult["result"] rtt = [] hopfrom = "" for hr in hopresult: if "error" in hr: rtt.append(hr["error"]) elif "x" in hr: rtt.append(str(hr["x"])) elif "edst" in hr: rtt.append("!") else: try: rtt.append(hr["rtt"]) except KeyError: rtt.append("*") hopfrom = hr["from"] if config.whois: ASN = whoisrecord(hopfrom) if hopfrom: try: if config.whois: if not isinstance(ASN, Exception): asn = ASN.asn owner = ASN.owner else: asn = "No AS: %s \"%s\"" % (type(ASN).__name__, ASN) owner = "Unknown" else: asn = "" owner = "" if not config.do_reverse_lookup: print(hopfrom, " ", asn, " ", owner," ", end=' ') else: reverse_lookup = lookup_ip(hopfrom) print(hopfrom, " ", reverse_lookup, " ", asn, " ", owner, " ", end=' ') except Exception as e: exc_type, exc_value, exc_traceback = sys.exc_info() print(hopfrom, " Lookup failed because of", exc_type.__name__, "(", exc_value, ") ", end=' ') print(rtt) else: print("Error: ", proberesult["error"]) print("") finally: if config.whois: pkl_file = open('whois.pkl', 'wb') pickle.dump(whois, pkl_file) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1740568315.572889 blaeu-2.2/blaeu.egg-info/0000775000175000017500000000000014757573374015517 5ustar00stephanestephane././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1740568315.0 blaeu-2.2/blaeu.egg-info/PKG-INFO0000664000175000017500000000631114757573373016614 0ustar00stephanestephaneMetadata-Version: 2.1 Name: blaeu Version: 2.2 Summary: Tools to create (and analyze) RIPE Atlas network measurements Home-page: https://framagit.org/bortzmeyer/blaeu Author: Stéphane Bortzmeyer Author-email: stephane+frama@bortzmeyer.org License: BSD Keywords: networking ripe atlas monitoring ip ping traceroute dig dns Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: System Administrators Classifier: Intended Audience :: Telecommunications Industry Classifier: Topic :: System :: Networking Classifier: License :: OSI Approved :: MIT License Classifier: Programming Language :: Python :: 3 Requires-Python: >=3 Provides-Extra: dev License-File: LICENCE Blaeu, creating measurements on RIPE Atlas probes ================================================= This is a set of `Python `__ programs to start distributed Internet measurements on the network of `RIPE Atlas probes `__, and to analyze their results. For installation, you can use usual Python tools, for instance: :: pip install blaeu (On a Debian machine, the prerequitises are packages python3-pip, python3-openssl, python3-dnspython, and python3-cymruwhois. This is only if you install manually, otherwise pip will install the dependencies.) Usage requires a RIPE Atlas API key (which itself requires a RIPE account), and RIPE Atlas credits. If you don’t have a RIPE account, `register first `__. Once you have an account, `create a key `__, grant it the right to ``schedule a new measurement``, and - use it in environment variable ``ATLASAUTH`` - or put the key in ``~/.atlas/auth`` If you don’t have Atlas credits, host a probe,or become a `LIR `__ or ask a friend. You can then use the six programs (``-h`` will give you a complete list of their options): - ``blaeu-reach target-IP-address`` (test reachability of the target, like ``ping``) - ``blaeu-traceroute target-IP-address`` (like ``traceroute``) - ``blaeu-resolve name`` (use the DNS to resolve the name) - ``blaeu-cert name`` (display the PKIX certificate) - ``blaeu-ntp name`` (test NTP) - ``blaeu-http name`` (test HTTP, only to anchors) You have here `some examples of use `__. You may also be interested by `my article at RIPE Labs `__. Blaeu requires Python 3. Note that `the old version `__ ran on Python 2 but is no longer maintained. (It was `partially documented at RIPE Labs `__.) Name ---- It comes from the `famous Dutch cartographer `__. The logo of the project comes from his “Theatrum Orbis Terrarum” (see `the source `__). Reference site -------------- `On FramaGit `__ Author ------ Stéphane Bortzmeyer stephane+frama@bortzmeyer.org ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1740568315.0 blaeu-2.2/blaeu.egg-info/SOURCES.txt0000664000175000017500000000044614757573373017406 0ustar00stephanestephaneBlaeu.py EXAMPLES.md LICENCE MANIFEST.in README.md README.rst TAGS blaeu-cert blaeu-http blaeu-ntp blaeu-reach blaeu-resolve blaeu-traceroute setup.py blaeu.egg-info/PKG-INFO blaeu.egg-info/SOURCES.txt blaeu.egg-info/dependency_links.txt blaeu.egg-info/requires.txt blaeu.egg-info/top_level.txt././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1740568315.0 blaeu-2.2/blaeu.egg-info/dependency_links.txt0000664000175000017500000000000114757573373021564 0ustar00stephanestephane ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1740568315.0 blaeu-2.2/blaeu.egg-info/requires.txt0000664000175000017500000000005714757573373020120 0ustar00stephanestephanecymruwhois dnspython pyopenssl [dev] pypandoc ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1740568315.0 blaeu-2.2/blaeu.egg-info/top_level.txt0000664000175000017500000000000614757573373020244 0ustar00stephanestephaneBlaeu ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1740568315.573889 blaeu-2.2/setup.cfg0000664000175000017500000000004614757573374014556 0ustar00stephanestephane[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1740568212.0 blaeu-2.2/setup.py0000664000175000017500000001300114757573224014434 0ustar00stephanestephane# -*- coding: utf-8 -*- """See: https://packaging.python.org/en/latest/distributing.html https://github.com/pypa/sampleproject """ # Always prefer setuptools over distutils from setuptools import setup, find_packages # To use a consistent encoding from codecs import open from os import path, stat here = path.abspath(path.dirname(__file__)) # Get the long description from the README file if not path.exists('README.rst') or \ stat('README.md').st_mtime > stat('README.rst').st_mtime: import pypandoc # https://pypi.org/project/pypandoc/ rst = pypandoc.convert_file('README.md', 'rst') f = open('README.rst','w+') f.write(rst) f.close() with open(path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = f.read() # Arguments marked as "Required" below must be included for upload to PyPI. # Fields marked as "Optional" may be commented out. setup( name='blaeu', # Required # Versions should comply with PEP 440: # https://www.python.org/dev/peps/pep-0440/ version="2.2", # WARNING: if you modify it here, also change Blaeu.py https://packaging.python.org/guides/single-sourcing-package-version/#single-sourcing-the-version # This is a one-line description or tagline of what your project does. This # corresponds to the "Summary" metadata field: # https://packaging.python.org/specifications/core-metadata/#summary description='Tools to create (and analyze) RIPE Atlas network measurements', # Required # This field corresponds to the "Description" metadata field: # https://packaging.python.org/specifications/core-metadata/#description-optional long_description=long_description, # Optional # This should be a valid link to your project's main homepage. # # This field corresponds to the "Home-Page" metadata field: # https://packaging.python.org/specifications/core-metadata/#home-page-optional url='https://framagit.org/bortzmeyer/blaeu', # Optional # This should be your name or the name of the organization which owns the # project. author='Stéphane Bortzmeyer', # Optional # This should be a valid email address corresponding to the author listed # above. author_email='stephane+frama@bortzmeyer.org', # Optional license = 'BSD', # Classifiers help users find your project by categorizing it. # # For a list of valid classifiers, see # https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ # Optional # How mature is this project? Common values are # 3 - Alpha # 4 - Beta # 5 - Production/Stable 'Development Status :: 5 - Production/Stable', # Indicate who your project is intended for 'Intended Audience :: System Administrators', 'Intended Audience :: Telecommunications Industry', 'Topic :: System :: Networking', # Pick your license as you wish 'License :: OSI Approved :: MIT License', # Specify the Python versions you support here. In particular, ensure # that you indicate whether you support Python 2, Python 3 or both. 'Programming Language :: Python :: 3' ], # This field adds keywords for your project which will appear on the # project page. What does your project relate to? # # Note that this is a string of words separated by whitespace, not a list. keywords='networking ripe atlas monitoring ip ping traceroute dig dns', # Optional # You can just specify package directories manually here if your project is # simple. Or you can use find_packages(). # # Alternatively, if you just want to distribute a single Python file, use # the `py_modules` argument instead as follows, which will expect a file # called `my_module.py` to exist: # py_modules=["Blaeu"], # packages=[], # Required # This field lists other packages that your project depends on to run. # Any package you put here will be installed by pip when your project is # installed, so they must be valid existing projects. # # For an analysis of "install_requires" vs pip's requirements files see: # https://packaging.python.org/en/latest/requirements.html install_requires=['cymruwhois', 'pyopenssl', 'dnspython'], # Optional # List additional groups of dependencies here (e.g. development # dependencies). Users will be able to install these using the "extras" # syntax, for example: # # $ pip install sampleproject[dev] # # Similar to `install_requires` above, these must be valid existing # projects. extras_require={ 'dev': ['pypandoc'] # Optional }, python_requires='>=3', # If there are data files included in your packages that need to be # installed, specify them here. # # If using Python 2.6 or earlier, then these have to be included in # MANIFEST.in as well. package_data={ # Optional }, # Although 'package_data' is the preferred approach, in some case you may # need to place data files outside of your packages. See: # https://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # # In this case, 'data_file' will be installed into '/my_data' data_files=[('share/doc/blaeu', ['README.md', 'EXAMPLES.md'])], # Optional # To provide executable scripts, entry points are officially # recommended but way too hard for me. scripts=['blaeu-reach', 'blaeu-resolve', 'blaeu-traceroute', 'blaeu-cert', 'blaeu-http', 'blaeu-ntp'], )