diff --git a/.gitignore b/.gitignore index 6d529b5..39c250f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ python-ping/ +__pycache__ *.pyc oping.c @@ -8,3 +9,8 @@ build/ ui/node_modules/ db/ + +meshping/ui/node_modules/ +meshping/db/ + +venv/ diff --git a/.pylintrc b/.pylintrc index d3d930b..e4f3034 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,11 +1,12 @@ [MASTER] extension-pkg-whitelist = netifaces +ignore-paths = meshping/.[^/]+/migrations [FORMAT] -max-line-length=120 +max-line-length=88 [MESSAGES CONTROL] -disable=E1101,E1102,E1103,C0111,C0103,W0613,W0108,W0212,R0903 +disable=missing-function-docstring,missing-class-docstring,missing-module-docstring,no-member,fixme [DESIGN] max-public-methods=100 diff --git a/meshping/manage.py b/meshping/manage.py new file mode 100755 index 0000000..e96c666 --- /dev/null +++ b/meshping/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meshping.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/meshping/meshping/__init__.py b/meshping/meshping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meshping/meshping/apps.py b/meshping/meshping/apps.py new file mode 100644 index 0000000..df86e5e --- /dev/null +++ b/meshping/meshping/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig +from .meshping.meshping_config import MeshpingConfig as MPCOnfig + + +class MeshpingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "meshping" + + # TODO decide for long-term layout: standard threads, async, scheduling library + # TODO start background threads for traceroute, peers + # TODO with the current thread model, django complains about db access before app + # initialization is completed + # TODO create background task for db housekeeping (prune_histograms) + # + # delayed import, otherwise we will get AppRegistryNotReady + # pylint: disable=import-outside-toplevel + def ready(self): + from .meshping.peering_thread import PeeringThread + from .meshping.ping_thread import PingThread + from .meshping.traceroute_thread import TracerouteThread + + mp_config = MPCOnfig() + peering_thread = PeeringThread(mp_config=mp_config, daemon=True) + ping_thread = PingThread(mp_config=mp_config, daemon=True) + traceroute_thread = TracerouteThread(mp_config=mp_config, daemon=True) + + peering_thread.start() + ping_thread.start() + traceroute_thread.start() diff --git a/meshping/meshping/asgi.py b/meshping/meshping/asgi.py new file mode 100644 index 0000000..4b48dcb --- /dev/null +++ b/meshping/meshping/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meshping.settings") + +application = get_asgi_application() diff --git a/meshping/meshping/jinja2.py b/meshping/meshping/jinja2.py new file mode 100644 index 0000000..aa087bd --- /dev/null +++ b/meshping/meshping/jinja2.py @@ -0,0 +1,14 @@ +from jinja2 import Environment +from django.urls import reverse +from django.templatetags.static import static + + +def environment(**options): + env = Environment(variable_start_string="{[", variable_end_string="]}", **options) + env.globals.update( + { + "static": static, + "url": reverse, + } + ) + return env diff --git a/meshping/meshping/meshping/__init__.py b/meshping/meshping/meshping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meshping/meshping/meshping/histodraw.py b/meshping/meshping/meshping/histodraw.py new file mode 100644 index 0000000..60d5107 --- /dev/null +++ b/meshping/meshping/meshping/histodraw.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# kate: space-indent on; indent-width 4; replace-tabs on; + +import socket +import os +from datetime import timedelta +import numpy as np +import pandas + +from PIL import Image, ImageDraw, ImageFont, ImageOps +from ..models import target_histograms + + +# How big do you want the squares to be? +SQSZ = 8 + + +# [ +# { +# "timestamp": 1743883200, +# "47": 2, +# "50": null, +# ... +# }, +# ... +# ] +def render_target(target): + histograms_df = target_histograms(target) + if histograms_df.empty: + return None + + # Normalize Buckets by transforming the number of actual pings sent + # into a float [0..1] indicating the grayness of that bucket. + biggestbkt = histograms_df.max().max() + histograms_df = histograms_df.div(biggestbkt, axis="index") + # prune outliers -> keep only values > 5% + histograms_df = histograms_df[histograms_df > 0.05] + # drop columns that contain only NaNs now + histograms_df = histograms_df.dropna(axis="columns", how="all") + # fill missing _rows_ (aka, hours) with rows of just NaN + histograms_df = histograms_df.asfreq("1h") + # replace all the NaNs with 0 + histograms_df = histograms_df.fillna(0) + + # detect dynamic range, and round to the nearest multiple of 10. + # this ensures that the ticks are drawn at powers of 2, which makes + # the graph more easily understandable. (I hope.) + # Btw: 27 // 10 * 10 # = 20 + # -27 // 10 * 10 # = -30 + # hmax needs to be nearest power of 10 + 1 for the top tick to be drawn. + hmin = histograms_df.columns.min() // 10 * 10 + hmax = histograms_df.columns.max() // 10 * 10 + 11 + + # Draw the graph in a pixels array which we then copy to an image + height = hmax - hmin + 1 + width = len(histograms_df) + pixels = np.zeros(width * height) + + for col, (_tstamp, histogram) in enumerate(histograms_df.iterrows()): + for bktval, bktgrayness in histogram.items(): + # ( y ) (x) + pixels[((hmax - bktval) * width) + col] = bktgrayness + + # copy pixels to an Image and paste that into the output image + graph = Image.new("L", (width, height)) + graph.putdata(pixels * 0xFF) + + # Scale graph so each Pixel becomes a square + width *= SQSZ + height *= SQSZ + + graph = graph.resize((width, height), Image.NEAREST) + graph.hmin = hmin + graph.hmax = hmax + graph.tmin = histograms_df.index.min() + graph.tmax = histograms_df.index.max() + return graph + + +# TODO maybe this method is a bit long? on the other hand, manual rendering code always +# tends to be tedious +# pylint: disable=too-many-locals,too-many-branches,too-many-statements +def render(targets, histogram_period): + rendered_graphs = [] + + for target in targets: + target_graph = render_target(target) + if target_graph is None: + raise ValueError(f"No data available for target {target}") + rendered_graphs.append(target_graph) + + width = histogram_period // 3600 * SQSZ + hmin = min(graph.hmin for graph in rendered_graphs) + hmax = max(graph.hmax for graph in rendered_graphs) + tmax = max(graph.tmax for graph in rendered_graphs) + height = (hmax - hmin) * SQSZ + + if len(rendered_graphs) == 1: + # Single graph -> use it as-is + graph = Image.new("L", (width, height), "white") + graph.paste( + ImageOps.invert(rendered_graphs[0]), (width - rendered_graphs[0].width, 0) + ) + else: + # Multiple graphs -> merge. + # This width/height may not match what we need for the output. + # Check for which graphs that is the case, and for these, + # create a new image that has the correct size and paste + # the graph into it. + resized_graphs = [] + for graph in rendered_graphs: + dtmax = (tmax - graph.tmax) // pandas.Timedelta(hours=1) + if graph.width != width or graph.height != height: + new_graph = Image.new("L", (width, height), "black") + new_graph.paste( + graph, + (width - graph.width - SQSZ * dtmax, (hmax - graph.hmax) * SQSZ), + ) + else: + new_graph = graph + + resized_graphs.append(new_graph) + + while len(resized_graphs) != 3: + resized_graphs.append(Image.new("L", (width, height), "black")) + + # Print the graph, on black background still. + graph = Image.merge("RGB", resized_graphs) + + # To get a white background, convert to HSV and set V=1. + # V currently contains the interesting information though, + # so move that to S first. + hsv = np.array(graph.convert("HSV")) + # Add V to S (not sure why adding works better than replacing, but it does) + hsv[:, :, 1] = hsv[:, :, 1] + hsv[:, :, 2] + # Set V to 1 + hsv[:, :, 2] = np.ones((height, width)) * 0xFF + graph = Image.fromarray(hsv, "HSV").convert("RGB") + + # position of the graph + graph_x = 70 + graph_y = 30 * len(targets) + 10 + + # im will hold the output image + im = Image.new("RGB", (graph_x + width + 20, graph_y + height + 100), "white") + im.paste(graph, (graph_x, graph_y)) + + # draw a rect around the graph + draw = ImageDraw.Draw(im) + draw.rectangle( + (graph_x, graph_y, graph_x + width - 1, graph_y + height - 1), outline=0x333333 + ) + + try: + font = ImageFont.truetype("DejaVuSansMono.ttf", 10) + lgfont = ImageFont.truetype("DejaVuSansMono.ttf", 16) + except IOError: + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 10 + ) + lgfont = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 16 + ) + + # Headline + if len(targets) == 1: # just black for a single graph + targets_with_colors = zip(targets, (0x000000,)) + else: # red, green, blue for multiple graphs + targets_with_colors = zip(targets, (0x0000FF, 0x00FF00, 0xFF0000)) + + for idx, (target, color) in enumerate(targets_with_colors): + headline_text = f"{socket.gethostname()} → {target.label}" + headline_width = lgfont.getlength(headline_text) + draw.text( + ((graph_x + width + 20 - headline_width) // 2, 30 * idx + 11), + headline_text, + color, + font=lgfont, + ) + + # Y axis ticks and annotations + for hidx in range(hmin, hmax, 5): + bottomrow = hidx - hmin + offset_y = height + graph_y - bottomrow * SQSZ - 1 + draw.line((graph_x - 2, offset_y, graph_x + 2, offset_y), fill=0xAAAAAA) + + ping = 2 ** (hidx / 10.0) + label = f"{ping:.2f}" + draw.text( + (graph_x - len(label) * 6 - 10, offset_y - 5), label, 0x333333, font=font + ) + + # Calculate the times at which the histogram begins and ends. + t_hist_end = ( + # Latest hour for which we have data... + tmax.tz_localize("Etc/UTC") + .tz_convert(os.environ.get("TZ", "Etc/UTC")) + .to_pydatetime() + # Plus the current hour which we're also drawing on screen + + timedelta(hours=1) + ) + + td_hours = histogram_period // 3600 + t_hist_begin = t_hist_end - timedelta(hours=td_hours) + + # X axis ticks - one every two hours + for col in range(1, width // SQSZ): + # We're now at hour indicated by col + if (t_hist_begin + timedelta(hours=col)).hour % 2 != 0: + continue + offset_x = graph_x + col * SQSZ + draw.line( + (offset_x, height + graph_y - 2, offset_x, height + graph_y + 2), + fill=0xAAAAAA, + ) + + # X axis annotations + # Create a temp image for the bottom label that we then rotate by 90° and attach to + # the other one since this stuff is rotated by 90° while we create it, all the + # coordinates are inversed... + tmpim = Image.new("RGB", (80, width + 20), "white") + tmpdraw = ImageDraw.Draw(tmpim) + + # Draw one annotation every four hours + for col in range(0, width // SQSZ + 1): + # We're now at hour indicated by col + tstamp = t_hist_begin + timedelta(hours=col) + if tstamp.hour % 4 != 0: + continue + offset_x = col * SQSZ + if tstamp.hour == 0: + tmpdraw.text( + (0, offset_x + 4), tstamp.strftime("%m-%d"), 0x333333, font=font + ) + tmpdraw.text((36, offset_x + 4), tstamp.strftime("%H:%M"), 0x333333, font=font) + + im.paste(tmpim.rotate(90, expand=1), (graph_x - 10, height + graph_y + 1)) + + # This worked pretty well for Tobi Oetiker... + tmpim = Image.new("RGB", (170, 13), "white") + tmpdraw = ImageDraw.Draw(tmpim) + tmpdraw.text((0, 0), "Meshping by Michael Ziegler", 0x999999, font=font) + im.paste(tmpim.rotate(270, expand=1), (width + graph_x + 7, graph_y)) + + return im diff --git a/meshping/meshping/meshping/ifaces.py b/meshping/meshping/meshping/ifaces.py new file mode 100644 index 0000000..90db9c3 --- /dev/null +++ b/meshping/meshping/meshping/ifaces.py @@ -0,0 +1,125 @@ +import socket +import ipaddress +import logging +import netifaces + + +class Ifaces4: + def __init__(self): + self.addrs = [] + self.networks = [] + # Get addresses from our interfaces + for iface in netifaces.interfaces(): + try: + ifaddrs = netifaces.ifaddresses(iface) + except ValueError: + logging.warning( + "Could not retrieve addresses of interface %s, ignoring interface", + iface, + exc_info=True, + ) + continue + + for family, addresses in ifaddrs.items(): + if family != socket.AF_INET: + continue + + for addrinfo in addresses: + self.addrs.append(ipaddress.ip_address(addrinfo["addr"])) + self.networks.append( + ipaddress.IPv4Network( + f"{ipaddress.ip_address(addrinfo['addr'])}/" + f"{ipaddress.ip_address(addrinfo['netmask'])}", + strict=False, + ) + ) + + def find_iface_for_network(self, target): + target = ipaddress.IPv4Address(target) + for addr in self.networks: + if target in addr: + return addr + return None + + def is_local(self, target): + return self.find_iface_for_network(target) is not None + + def is_interface(self, target): + target = ipaddress.IPv4Address(target) + return target in self.addrs + + +class Ifaces6: + def __init__(self): + self.addrs = [] + self.networks = [] + # Get addresses from our interfaces + for iface in netifaces.interfaces(): + try: + ifaddrs = netifaces.ifaddresses(iface) + except ValueError: + logging.warning( + "Could not retrieve addresses of interface %s, ignoring interface", + iface, + exc_info=True, + ) + continue + + for family, addresses in ifaddrs.items(): + if family != socket.AF_INET6: + continue + + for addrinfo in addresses: + if "%" in addrinfo["addr"]: + part_addr, part_iface = addrinfo["addr"].split("%", 1) + assert part_iface == iface + addrinfo["addr"] = part_addr + + # netmask is ffff:ffff:ffff:etc:ffff/128 for some reason, + # we only need the length + addrinfo["netmask"] = int(addrinfo["netmask"].split("/")[1], 10) + + self.addrs.append(ipaddress.ip_address(addrinfo["addr"])) + self.networks.append( + ipaddress.IPv6Network( + f"{ipaddress.ip_address(addrinfo['addr'])}/" + f"{addrinfo['netmask']}", + strict=False, + ) + ) + + def find_iface_for_network(self, target): + target = ipaddress.IPv6Address(target) + for addr in self.networks: + if target in addr: + return addr + return None + + def is_local(self, target): + return self.find_iface_for_network(target) is not None + + def is_interface(self, target): + target = ipaddress.IPv4Address(target) + return target in self.addrs + + +def test(): + if4 = Ifaces4() + for target in ( + "192.168.0.1", + "10.159.1.1", + "10.159.1.2", + "10.5.1.2", + "10.9.9.9", + "8.8.8.8", + "192.168.44.150", + ): + print(f"{target} -> {if4.is_local(target)}") + + if6 = Ifaces6() + for target in ("2001:4860:4860::8888", "2001:4860:4860::8844", "::1"): + print(f"{target} -> {if6.is_local(target)}") + + +if __name__ == "__main__": + test() diff --git a/meshping/meshping/meshping/meshping_config.py b/meshping/meshping/meshping/meshping_config.py new file mode 100644 index 0000000..1a7f18d --- /dev/null +++ b/meshping/meshping/meshping/meshping_config.py @@ -0,0 +1,24 @@ +# TODO add implementation for values from environment +# TODO decide about option for config file, and automatic combination with docker +# environment variables +# +# pylint: disable=too-many-instance-attributes,too-few-public-methods +class MeshpingConfig: + def __init__(self): + self.ping_timeout = 5 + self.ping_interval = 30 + + self.traceroute_interval = 900 + self.traceroute_timeout = 0.5 + self.traceroute_packets = 1 + self.traceroute_ratelimit_interval = 2 + + self.peers = "" + self.peering_interval = 30 + self.peering_timeout = 30 + + # TODO make config options in this object consistent, use seconds + self.whois_cache_validiy_h = 72 + + # TODO make config options in this object consistent, use seconds + self.histogram_period = 3 diff --git a/meshping/meshping/meshping/peering_thread.py b/meshping/meshping/meshping/peering_thread.py new file mode 100644 index 0000000..cf66524 --- /dev/null +++ b/meshping/meshping/meshping/peering_thread.py @@ -0,0 +1,70 @@ +import json +import logging +import time +from threading import Thread +import requests +from .ifaces import Ifaces4, Ifaces6 +from ..models import Target, Meta + + +# TODO test this code, has not even run once + + +class PeeringThread(Thread): + def __init__(self, mp_config, *args, **kwargs): + self.mp_config = mp_config + super().__init__(*args, **kwargs) + + def run(self): + if self.mp_config.peers: + peers = self.mp_config.peers.split(",") + else: + return + + while True: + if4 = Ifaces4() + if6 = Ifaces6() + + def is_local(addr): + try: + return if4.is_local(addr) + except ValueError: + pass + try: + return if6.is_local(addr) + except ValueError: + pass + return False + + peer_targets = [] + + # TODO feels clumsy to get the meta objects one by one, maybe there is a + # more elegant way, i.e. something that maps to a join under the hood? + for target in Target.objects.all(): + target_meta, _created = Meta.objects.get_or_create(target=target) + if target_meta.is_foreign: + continue + peer_targets.append( + { + "name": target.name, + "addr": target.addr, + "local": is_local(target.addr), + } + ) + + for peer in peers: + try: + requests.post( + f"http://{peer}/peer", + headers={ + "Content-Type": "application/json", + }, + data=json.dumps({"targets": peer_targets}), + timeout=self.mp_config.peering_timeout, + ) + # TODO decide if this general exception catch is the correct way + # pylint: disable=broad-exception-caught + except Exception as err: + logging.warning("Could not connect to peer %s: %s", peer, err) + + time.sleep(self.mp_config.peering_interval) diff --git a/meshping/meshping/meshping/ping_thread.py b/meshping/meshping/meshping/ping_thread.py new file mode 100644 index 0000000..54335d0 --- /dev/null +++ b/meshping/meshping/meshping/ping_thread.py @@ -0,0 +1,130 @@ +import time +import math +from threading import Thread + +# pylint cannot read the definitions in the shared object +# pylint: disable=no-name-in-module +from oping import PingObj, PingError +from ..models import Histogram, Target, Statistics, Meta, TargetState + + +INTERVAL = 30 +FAC_15m = math.exp(-INTERVAL / (15 * 60.0)) +FAC_6h = math.exp(-INTERVAL / (6 * 60 * 60.0)) +FAC_24h = math.exp(-INTERVAL / (24 * 60 * 60.0)) + + +class PingThread(Thread): + def __init__(self, mp_config, *args, **kwargs): + self.mp_config = mp_config + super().__init__(*args, **kwargs) + + @staticmethod + def exp_avg(current_avg, add_value, factor): + if current_avg is None: + return add_value + return (current_avg * factor) + (add_value * (1 - factor)) + + def process_ping_result(self, timestamp, hostinfo): + target = Target.objects.filter(addr=hostinfo["addr"]).first() + # TODO proper error handling instead of assert + assert target is not None + target_stats, _created = Statistics.objects.get_or_create(target=target) + target_meta, _created = Meta.objects.get_or_create(target=target) + + target_stats.sent += 1 + + if hostinfo["latency"] != -1: + target_meta.state = TargetState.UP + target_stats.recv += 1 + target_stats.last = hostinfo["latency"] + target_stats.sum += target_stats.last + target_stats.max = max(target_stats.max, target_stats.last) + target_stats.min = min(target_stats.min, target_stats.last) + target_stats.avg15m = self.exp_avg( + target_stats.avg15m, target_stats.last, FAC_15m + ) + target_stats.avg6h = self.exp_avg( + target_stats.avg6h, target_stats.last, FAC_6h + ) + target_stats.avg24h = self.exp_avg( + target_stats.avg24h, target_stats.last, FAC_24h + ) + + bucket, _created = Histogram.objects.get_or_create( + target=target, + timestamp=timestamp // 3600 * 3600, + bucket=int(math.log(hostinfo["latency"], 2) * 10), + ) + bucket.count += 1 + bucket.save() + + else: + target_meta.state = TargetState.DOWN + target_stats.lost += 1 + + target_stats.save() + target_meta.save() + + def run(self): + pingobj = PingObj() + pingobj.set_timeout(self.mp_config.ping_timeout) + + next_ping = time.time() + 0.1 + + current_targets = set() + + while True: + now = time.time() + next_ping = now + self.mp_config.ping_interval + + # Run DB housekeeping + # TODO has nothing to do with pings, find a better place (probably new + # housekeeping thread) + Histogram.objects.filter( + timestamp__lt=(now - self.mp_config.histogram_period * 24 * 3600) + ).delete() + + unseen_targets = current_targets.copy() + for target in Target.objects.all(): + if target.addr not in current_targets: + current_targets.add(target.addr) + try: + pingobj.add_host(target.addr.encode("utf-8")) + except PingError as err: + # TODO make this safe, do not just assume the object exists + target_meta = Meta.objects.filter(target=target)[0] + target_meta.state = TargetState.ERROR + target_meta.error = err.args[0].decode("utf-8") + target_meta.save() + if target.addr in unseen_targets: + unseen_targets.remove(target.addr) + + for target_addr in unseen_targets: + current_targets.remove(target_addr) + try: + pingobj.remove_host(target_addr.encode("utf-8")) + except PingError: + # Host probably not there anyway + pass + + # If we don't have any targets, we're done for now -- just sleep + if not current_targets: + time.sleep(max(0, next_ping - time.time())) + continue + + # We do have targets, so first, let's ping them + pingobj.send() + + for hostinfo in pingobj.get_hosts(): + hostinfo["addr"] = hostinfo["addr"].decode("utf-8") + + try: + self.process_ping_result(now, hostinfo) + except LookupError: + # ping takes a while. it's possible that while we were busy, this + # target has been deleted from the DB. If so, forget about it. + if hostinfo["addr"] in current_targets: + current_targets.remove(hostinfo["addr"]) + + time.sleep(max(0, next_ping - time.time())) diff --git a/meshping/meshping/meshping/socklib.py b/meshping/meshping/meshping/socklib.py new file mode 100644 index 0000000..e569186 --- /dev/null +++ b/meshping/meshping/meshping/socklib.py @@ -0,0 +1,109 @@ +import socket + +from icmplib.sockets import ICMPv4Socket, ICMPv6Socket +from icmplib.exceptions import ICMPSocketError, TimeoutExceeded +from icmplib.models import ICMPRequest +from icmplib.utils import unique_identifier + +# see /usr/include/linux/in.h +IP_MTU_DISCOVER = 10 +IP_PMTUDISC_DO = 2 +IP_MTU = 14 +IP_HEADER_LEN = 20 +ICMP_HEADER_LEN = 8 + +# see /usr/include/linux/in6.h +IPV6_MTU_DISCOVER = 23 +IPV6_PMTUDISC_DO = 2 +IPV6_MTU = 24 +IPV6_HEADER_LEN = 40 +ICMPV6_HEADER_LEN = 8 + + +def reverse_lookup(ip): + try: + return socket.gethostbyaddr(ip)[0] + except socket.herror: + return ip + + +class PMTUDv4Socket(ICMPv4Socket): + # pylint: disable=redefined-builtin + def _create_socket(self, type): + sock = super()._create_socket(type) + sock.setsockopt(socket.IPPROTO_IP, IP_MTU_DISCOVER, IP_PMTUDISC_DO) + return sock + + def get_header_len(self): + return IP_HEADER_LEN + ICMP_HEADER_LEN + + def get_mtu(self): + return self._sock.getsockopt(socket.IPPROTO_IP, IP_MTU) + + def send(self, request): + self._sock.connect((request.destination, 0)) + return super().send(request) + + +class PMTUDv6Socket(ICMPv6Socket): + # pylint: disable=redefined-builtin + def _create_socket(self, type): + sock = super()._create_socket(type) + sock.setsockopt(socket.IPPROTO_IPV6, IPV6_MTU_DISCOVER, IPV6_PMTUDISC_DO) + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_DONTFRAG, 1) + return sock + + def get_header_len(self): + return IPV6_HEADER_LEN + ICMPV6_HEADER_LEN + + def get_mtu(self): + return self._sock.getsockopt(socket.IPPROTO_IPV6, IPV6_MTU) + + def send(self, request): + self._sock.connect((request.destination, 0)) + return super().send(request) + + +def ip_pmtud(ip): + mtu = 9999 + + try: + addrinfo = socket.getaddrinfo(ip, 0, type=socket.SOCK_DGRAM)[0] + except socket.gaierror as err: + return {"state": "error", "error": str(err), "mtu": mtu} + + if addrinfo[0] == socket.AF_INET6: + sock = PMTUDv6Socket(address=None, privileged=True) + else: + sock = PMTUDv4Socket(address=None, privileged=True) + + with sock: + ping_id = unique_identifier() + for sequence in range(30): + request = ICMPRequest( + destination=ip, + id=ping_id, + sequence=sequence, + payload_size=mtu - sock.get_header_len(), + ) + try: + # deliberately send a way-too-large packet to provoke an error. + # if the ping is successful, we found the MTU. + sock.send(request) + sock.receive(request, 1) + return {"state": "up", "mtu": mtu} + + except TimeoutExceeded: + # Target down, but no error -> MTU is probably fine. + return {"state": "down", "mtu": mtu} + + except (ICMPSocketError, OSError) as err: + if "Errno 90" not in str(err): + return {"state": "error", "error": str(err), "mtu": mtu} + + new_mtu = sock.get_mtu() + if new_mtu == mtu: + break + mtu = new_mtu + + return {"state": "ttl_exceeded", "mtu": mtu} diff --git a/meshping/meshping/meshping/traceroute_thread.py b/meshping/meshping/meshping/traceroute_thread.py new file mode 100644 index 0000000..23c0b22 --- /dev/null +++ b/meshping/meshping/meshping/traceroute_thread.py @@ -0,0 +1,108 @@ +import logging +import time +from threading import Thread +from icmplib import traceroute +from ipwhois import IPWhois, IPDefinedError +from netaddr import IPAddress, IPNetwork +from .socklib import reverse_lookup, ip_pmtud +from ..models import Target, Meta + + +class TracerouteThread(Thread): + def __init__(self, mp_config, *args, **kwargs): + self.mp_config = mp_config + self.whois_cache = {} + super().__init__(*args, **kwargs) + + def whois(self, hop_address): + # If we know this address already and it's up-to-date, skip it + now = int(time.time()) + if ( + hop_address in self.whois_cache + and self.whois_cache[hop_address].get("last_check", 0) + + self.mp_config.whois_cache_validiy_h * 3600 + < now + ): + return self.whois_cache[hop_address] + + # Check if the IP is private or reserved + addr = IPAddress(hop_address) + # TODO split out into separate function and allow configuration + # pylint: disable=too-many-boolean-expressions + if ( + addr.version == 4 + and ( + addr in IPNetwork("10.0.0.0/8") + or addr in IPNetwork("172.16.0.0/12") + or addr in IPNetwork("192.168.0.0/16") + or addr in IPNetwork("100.64.0.0/10") + ) + ) or (addr.version == 6 and addr not in IPNetwork("2000::/3")): + return {} + + # It's not, look up whois info + try: + self.whois_cache[hop_address] = dict( + IPWhois(hop_address).lookup_rdap(), last_check=now + ) + except IPDefinedError: + # RFC1918, RFC6598 or something else + return {} + # we do not have a global exception handler atm, thus we want to catch all + # errors here + # pylint: disable=broad-exception-caught + except Exception as err: + logging.warning("Could not query whois for IP %s: %s", hop_address, err) + return self.whois_cache[hop_address] + + def run(self): + while True: + now = time.time() + next_run = now + self.mp_config.traceroute_interval + pmtud_cache = {} + for target in Target.objects.all(): + target_meta, _created = Meta.objects.get_or_create(target=target) + + trace = traceroute( + target.addr, + fast=True, + timeout=self.mp_config.traceroute_timeout, + count=self.mp_config.traceroute_packets, + ) + hopaddrs = [hop.address for hop in trace] + hoaddrs_set = set(hopaddrs) + target_meta.route_loop = ( + len(hopaddrs) != len(hoaddrs_set) and len(hoaddrs_set) > 1 + ) + + trace_hops = [] + for hop in trace: + if hop.address not in pmtud_cache: + pmtud_cache[hop.address] = ip_pmtud(hop.address) + + trace_hops.append( + { + "name": reverse_lookup(hop.address), + "distance": hop.distance, + "address": hop.address, + "max_rtt": hop.max_rtt, + "pmtud": pmtud_cache[hop.address], + "whois": self.whois(hop.address), + "time": now, + } + ) + + target_meta.traceroute = trace_hops + if trace_hops and trace_hops[-1]["address"] == target.addr: + # Store last known good traceroute + target_meta.lkgt = trace_hops + + # Running a bunch'a traceroutes all at once might trigger our default + # gw's rate limiting if it receives too many packets with a ttl of 1 + # too quickly. Let's go a bit slower so that it doesn't stop sending + # "ttl exceeded" replies and messing up our results. + time.sleep(self.mp_config.traceroute_ratelimit_interval) + + target_meta.save() + + time.sleep(max(0, next_run - time.time())) diff --git a/meshping/meshping/migrations/0001_initial.py b/meshping/meshping/migrations/0001_initial.py new file mode 100644 index 0000000..3b3b4b4 --- /dev/null +++ b/meshping/meshping/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.7 on 2025-04-02 18:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Target', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('addr', models.CharField(max_length=255, unique=True)), + ('name', models.CharField(max_length=255)), + ], + options={ + 'unique_together': {('addr', 'name')}, + }, + ), + migrations.CreateModel( + name='Statistics', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('field', models.CharField(max_length=255)), + ('value', models.FloatField()), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meshping.target')), + ], + ), + migrations.CreateModel( + name='Meta', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('field', models.CharField(max_length=255)), + ('value', models.CharField(max_length=255)), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meshping.target')), + ], + ), + migrations.CreateModel( + name='Histogram', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.IntegerField()), + ('bucket', models.IntegerField()), + ('count', models.IntegerField(default=1)), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meshping.target')), + ], + ), + ] diff --git a/meshping/meshping/migrations/0002_remove_statistics_field_remove_statistics_id_and_more.py b/meshping/meshping/migrations/0002_remove_statistics_field_remove_statistics_id_and_more.py new file mode 100644 index 0000000..c696471 --- /dev/null +++ b/meshping/meshping/migrations/0002_remove_statistics_field_remove_statistics_id_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 5.1.7 on 2025-04-02 20:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meshping', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='statistics', + name='field', + ), + migrations.RemoveField( + model_name='statistics', + name='id', + ), + migrations.RemoveField( + model_name='statistics', + name='value', + ), + migrations.AddField( + model_name='statistics', + name='avg24h', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='avg5m', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='avg6h', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='last', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='lost', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='max', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='min', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='recv', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='sent', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='statistics', + name='sum', + field=models.FloatField(default=0.0), + ), + migrations.AlterField( + model_name='statistics', + name='target', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='meshping.target'), + ), + ] diff --git a/meshping/meshping/migrations/0003_rename_avg5m_statistics_avg15m.py b/meshping/meshping/migrations/0003_rename_avg5m_statistics_avg15m.py new file mode 100644 index 0000000..4aabbf7 --- /dev/null +++ b/meshping/meshping/migrations/0003_rename_avg5m_statistics_avg15m.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-04-04 14:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('meshping', '0002_remove_statistics_field_remove_statistics_id_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='statistics', + old_name='avg5m', + new_name='avg15m', + ), + ] diff --git a/meshping/meshping/migrations/0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more.py b/meshping/meshping/migrations/0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more.py new file mode 100644 index 0000000..6f9c26f --- /dev/null +++ b/meshping/meshping/migrations/0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 5.1.7 on 2025-04-05 15:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("meshping", "0003_rename_avg5m_statistics_avg15m"), + ] + + operations = [ + migrations.RemoveField( + model_name="meta", + name="field", + ), + migrations.RemoveField( + model_name="meta", + name="id", + ), + migrations.RemoveField( + model_name="meta", + name="value", + ), + migrations.AddField( + model_name="meta", + name="lkgt", + field=models.JSONField(default=[], max_length=2048), + ), + migrations.AddField( + model_name="meta", + name="route_loop", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="meta", + name="state", + field=models.CharField( + choices=[("up", "Up"), ("down", "Down"), ("unknown", "Unknown")], + default="down", + max_length=10, + ), + ), + migrations.AddField( + model_name="meta", + name="traceroute", + field=models.JSONField(default=[], max_length=2048), + ), + migrations.AlterField( + model_name="meta", + name="target", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="meshping.target", + ), + ), + migrations.AlterField( + model_name="statistics", + name="min", + field=models.FloatField(default=float("inf")), + ), + ] diff --git a/meshping/meshping/migrations/0005_meta_error_alter_meta_state.py b/meshping/meshping/migrations/0005_meta_error_alter_meta_state.py new file mode 100644 index 0000000..ab4d553 --- /dev/null +++ b/meshping/meshping/migrations/0005_meta_error_alter_meta_state.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.7 on 2025-04-05 16:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "meshping", + "0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="meta", + name="error", + field=models.CharField(default=None, max_length=255, null=True), + ), + migrations.AlterField( + model_name="meta", + name="state", + field=models.CharField( + choices=[ + ("up", "Up"), + ("down", "Down"), + ("unknown", "Unknown"), + ("error", "Error"), + ], + default="down", + max_length=10, + ), + ), + ] diff --git a/meshping/meshping/migrations/0006_alter_meta_lkgt_alter_meta_traceroute.py b/meshping/meshping/migrations/0006_alter_meta_lkgt_alter_meta_traceroute.py new file mode 100644 index 0000000..8e6dd61 --- /dev/null +++ b/meshping/meshping/migrations/0006_alter_meta_lkgt_alter_meta_traceroute.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-04-05 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("meshping", "0005_meta_error_alter_meta_state"), + ] + + operations = [ + migrations.AlterField( + model_name="meta", + name="lkgt", + field=models.JSONField(default=list, max_length=2048), + ), + migrations.AlterField( + model_name="meta", + name="traceroute", + field=models.JSONField(default=list, max_length=2048), + ), + ] diff --git a/meshping/meshping/migrations/0007_meta_is_foreign.py b/meshping/meshping/migrations/0007_meta_is_foreign.py new file mode 100644 index 0000000..d0c2b0f --- /dev/null +++ b/meshping/meshping/migrations/0007_meta_is_foreign.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-04-05 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("meshping", "0006_alter_meta_lkgt_alter_meta_traceroute"), + ] + + operations = [ + migrations.AddField( + model_name="meta", + name="is_foreign", + field=models.BooleanField(default=False), + ), + ] diff --git a/meshping/meshping/migrations/0008_alter_meta_state.py b/meshping/meshping/migrations/0008_alter_meta_state.py new file mode 100644 index 0000000..3081c20 --- /dev/null +++ b/meshping/meshping/migrations/0008_alter_meta_state.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.7 on 2025-04-06 19:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("meshping", "0007_meta_is_foreign"), + ] + + operations = [ + migrations.AlterField( + model_name="meta", + name="state", + field=models.CharField( + choices=[ + ("up", "Up"), + ("down", "Down"), + ("unknown", "Unknown"), + ("error", "Error"), + ], + default="unknown", + max_length=10, + ), + ), + ] diff --git a/meshping/meshping/migrations/__init__.py b/meshping/meshping/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py new file mode 100644 index 0000000..9ec6ac8 --- /dev/null +++ b/meshping/meshping/models.py @@ -0,0 +1,113 @@ +from itertools import zip_longest +from django.db import models +import pandas + + +# TODO decide on max_length, even though ignored by sqlite +class Target(models.Model): + addr = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255) + + # pylint: disable=too-few-public-methods + class Meta: + unique_together = ("addr", "name") + + @property + def label(self): + if self.name == self.addr: + return self.name + return f"{self.name} ({self.addr})" + + +# TODO uniqueness constraint `UNIQUE (target_id, timestamp, bucket)` +class Histogram(models.Model): + target = models.ForeignKey(Target, on_delete=models.CASCADE) + timestamp = models.IntegerField() + bucket = models.IntegerField() + count = models.IntegerField(default=1) + + +class Statistics(models.Model): + target = models.OneToOneField(Target, on_delete=models.CASCADE, primary_key=True) + sent = models.FloatField(default=0.0) + lost = models.FloatField(default=0.0) + recv = models.FloatField(default=0.0) + sum = models.FloatField(default=0.0) + last = models.FloatField(default=0.0) + max = models.FloatField(default=0.0) + min = models.FloatField(default=float("inf")) + avg15m = models.FloatField(default=0.0) + avg6h = models.FloatField(default=0.0) + avg24h = models.FloatField(default=0.0) + + +# pylint: disable=too-many-ancestors +class TargetState(models.TextChoices): + UP = "up" + DOWN = "down" + UNKNOWN = "unknown" + ERROR = "error" + + +# TODO decide on max_length, even though ignored by sqlite +# TODO uniqueness constraint `UNIQUE (target_id, field)` +class Meta(models.Model): + target = models.OneToOneField(Target, on_delete=models.CASCADE, primary_key=True) + state = models.CharField( + max_length=10, choices=TargetState.choices, default=TargetState.UNKNOWN + ) + route_loop = models.BooleanField(default=False) + traceroute = models.JSONField(max_length=2048, default=list) + # lkgt = last known good traceroute + lkgt = models.JSONField(max_length=2048, default=list) + error = models.CharField(max_length=255, null=True, default=None) + is_foreign = models.BooleanField(default=False) + + +# TODO consider making this a Target property, but take care that Histogram is defined +# before Target +# +# pivot method: flip the dataframe: turn each value of the "bucket" DB column into a +# separate column in the DF, using the timestamp as the index and the count for the +# values. None-existing positions in the DF are filled with zero. +def target_histograms(target): + df = pandas.DataFrame.from_dict( + Histogram.objects.filter(target=target).order_by("timestamp", "bucket").values() + ) + df["timestamp"] = pandas.to_datetime(df["timestamp"], unit="s") + return df.pivot( + index="timestamp", + columns="bucket", + values="count", + ).fillna(0) + + +# TODO consider making this a Target property, but take care that Meta is defined +# before Target +def target_traceroute(target): + target_meta, _created = Meta.objects.get_or_create(target=target) + + curr = target_meta.traceroute + lkgt = target_meta.lkgt # last known good traceroute + if not curr or not lkgt or len(lkgt) < len(curr): + # we probably don't know all the nodes, but the ones we do know are up + return [dict(hop, state="up") for hop in curr] + if curr[-1]["address"] == target.addr: + # Trace has reached the target itself, thus all hops are up + return [dict(hop, state="up") for hop in curr] + + # Check with lkgt to see which hops are still there + result = [] + for lkgt_hop, curr_hop in zip_longest(lkgt, curr): + if lkgt_hop is None: + # This should not be able to happen, because we checked + # len(lkgt) < len(curr) above. + raise ValueError("last known good traceroute: hop is None") + if curr_hop is None: + # hops missing from current traceroute are down + result.append(dict(lkgt_hop, state="down")) + elif curr_hop.get("address") != lkgt_hop.get("address"): + result.append(dict(curr_hop, state="different")) + else: + result.append(dict(curr_hop, state="up")) + return result diff --git a/meshping/meshping/settings.py b/meshping/meshping/settings.py new file mode 100644 index 0000000..dafc6cb --- /dev/null +++ b/meshping/meshping/settings.py @@ -0,0 +1,44 @@ +from pathlib import Path +import os + + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = "django-insecure-2svcr!j9%^_(^)$%)vvt1j&0$w)&_l&oxacnb1mkurd4f--xmg" +DEBUG = True +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "django.contrib.staticfiles", + "meshping", +] + +ROOT_URLCONF = "meshping.urls" + +# TODO the APP_DIRS seem to not work as I imagined for the jinja2 backend +TEMPLATES = [ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [ + os.path.join(BASE_DIR, "meshping/templates"), + ], + "APP_DIRS": False, + "OPTIONS": { + "environment": "meshping.jinja2.environment", + }, + }, +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db" / "db.sqlite3", + } +} + +STATIC_URL = "ui/" +STATICFILES_DIRS = [ + BASE_DIR / "ui", +] + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/meshping/meshping/templates/index.html.j2 b/meshping/meshping/templates/index.html.j2 new file mode 100644 index 0000000..fa19cc6 --- /dev/null +++ b/meshping/meshping/templates/index.html.j2 @@ -0,0 +1,216 @@ + + +
+