├── .drone.yml ├── .gitignore ├── .pylintrc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-manifest-latest.tmpl ├── docker-manifest-tagged.tmpl ├── examples ├── 2025-03_Vortrag-CLT-Meshping.pdf ├── dhcpd.conf ├── docker-compose.yaml ├── grafana.json ├── grafana_overview.json ├── heatmap.png ├── heatmap4.png ├── heatmap5.png ├── heatmap6.png ├── ui-loop-detected.png ├── ui-mobile.png ├── ui-netmap-colored.png ├── ui-netmap.png ├── ui-traceroute.png └── ui.png ├── features ├── basics.feature ├── environment.py ├── requirements.txt └── steps │ └── basics.py ├── oping-py ├── oping.pyx └── setup.py ├── requirements.txt ├── run-dev.sh ├── src ├── api.py ├── db.py ├── histodraw.py ├── ifaces.py ├── meshping.py ├── peers.py ├── socklib.py └── templates │ ├── index.html │ └── network.puml └── ui ├── package-lock.json ├── package.json └── src └── main.js /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | name: tests 4 | 5 | platform: 6 | arch: amd64 7 | os: linux 8 | 9 | steps: 10 | - name: meshping 11 | image: alpine:3.20 12 | environment: 13 | MESHPING_DATABASE_PATH: /tmp 14 | MESHPING_PING_TIMEOUT: 1 15 | MESHPING_PING_INTERVAL: 2 16 | MESHPING_PEERING_INTERVAL: 10 17 | MESHPING_PEERS: "pylint-and-test:31337" 18 | commands: 19 | - apk add --no-cache python3 python3-dev py3-pip musl-dev liboping-dev make gcc bash dumb-init py3-netifaces py3-pillow ttf-dejavu py3-pandas cython tzdata 20 | - pip3 install --break-system-packages -r requirements.txt 21 | - cd /drone/src/oping-py && python3 setup.py build && python3 setup.py install 22 | - cd /drone/src && dumb-init -- python3 src/meshping.py 23 | detach: true 24 | 25 | - name: pylint-and-test 26 | image: alpine:3.20 27 | commands: 28 | - apk add --no-cache python3 py3-pip py3-netifaces py3-pillow bash py3-pandas 29 | - pip3 install --break-system-packages pylint 30 | - pip3 install --break-system-packages -r requirements.txt 31 | - pip3 install --break-system-packages -r features/requirements.txt 32 | - bash -c "while ! nc -z meshping 9922; do sleep 1; done" 33 | - pylint --fail-under 9 src/ 34 | - python3 -m behave 35 | 36 | --- 37 | kind: pipeline 38 | name: linux-amd64-latest 39 | 40 | platform: 41 | arch: amd64 42 | os: linux 43 | 44 | steps: 45 | - name: set "latest" tag 46 | image: alpine:3.20 47 | commands: 48 | - echo -n "latest-amd64" > .tags 49 | when: 50 | branch: master 51 | 52 | - name: set "staging" tag 53 | image: alpine:3.20 54 | commands: 55 | - echo -n "staging-amd64" > .tags 56 | when: 57 | branch: staging 58 | 59 | - name: build docker image 60 | image: plugins/docker 61 | settings: 62 | repo: svedrin/meshping 63 | username: svedrin 64 | password: 65 | from_secret: docker-registry-pw 66 | cache_from: "svedrin/meshping:latest-amd64" 67 | 68 | 69 | trigger: 70 | branch: 71 | - master 72 | - staging 73 | event: 74 | exclude: 75 | - pull_request 76 | 77 | depends_on: 78 | - tests 79 | 80 | --- 81 | kind: pipeline 82 | name: linux-arm64-latest 83 | 84 | platform: 85 | arch: arm64 86 | os: linux 87 | 88 | steps: 89 | - name: set "latest-arm64" tag 90 | image: alpine:3.20 91 | commands: 92 | - echo -n "latest-arm64" > .tags 93 | when: 94 | branch: master 95 | 96 | - name: set "staging-arm64" tag 97 | image: alpine:3.20 98 | commands: 99 | - echo -n "staging-amd64" > .tags 100 | when: 101 | branch: staging 102 | 103 | - name: build docker image 104 | image: plugins/docker 105 | settings: 106 | repo: svedrin/meshping 107 | username: svedrin 108 | password: 109 | from_secret: docker-registry-pw 110 | cache_from: "svedrin/meshping:latest-arm64" 111 | platforms: linux/arm64 112 | 113 | trigger: 114 | branch: 115 | - master 116 | - staging 117 | event: 118 | exclude: 119 | - pull_request 120 | 121 | depends_on: 122 | - tests 123 | 124 | --- 125 | kind: pipeline 126 | name: linux-armv7l-latest 127 | 128 | platform: 129 | arch: arm 130 | os: linux 131 | 132 | steps: 133 | - name: set "latest" tag 134 | image: alpine:3.20 135 | commands: 136 | - echo -n "latest-armv7l" > .tags 137 | when: 138 | branch: master 139 | 140 | - name: set "staging" tag 141 | image: alpine:3.20 142 | commands: 143 | - echo -n "staging-armv7l" > .tags 144 | when: 145 | branch: staging 146 | 147 | - name: build docker image 148 | image: plugins/docker 149 | settings: 150 | repo: svedrin/meshping 151 | username: svedrin 152 | password: 153 | from_secret: docker-registry-pw 154 | cache_from: "svedrin/meshping:latest-armv7l" 155 | debug: true 156 | 157 | trigger: 158 | branch: 159 | - master 160 | - staging 161 | event: 162 | exclude: 163 | - pull_request 164 | 165 | depends_on: 166 | - tests 167 | 168 | 169 | --- 170 | kind: pipeline 171 | type: docker 172 | name: manifest-latest 173 | 174 | steps: 175 | - name: publish 176 | image: plugins/manifest:1.4 177 | settings: 178 | ignore_missing: true 179 | spec: docker-manifest-latest.tmpl 180 | username: svedrin 181 | password: 182 | from_secret: docker-registry-pw 183 | 184 | trigger: 185 | branch: 186 | - master 187 | - staging 188 | event: 189 | exclude: 190 | - pull_request 191 | 192 | depends_on: 193 | - linux-amd64-latest 194 | - linux-armv7l-latest 195 | - linux-arm64-latest 196 | 197 | 198 | 199 | --- 200 | kind: pipeline 201 | name: linux-amd64-tagged 202 | 203 | platform: 204 | arch: amd64 205 | os: linux 206 | 207 | steps: 208 | - name: set version tag 209 | image: alpine:3.20 210 | commands: 211 | - echo -n "${DRONE_TAG}-amd64" > .tags 212 | 213 | - name: build docker image 214 | image: plugins/docker 215 | settings: 216 | repo: svedrin/meshping 217 | username: svedrin 218 | password: 219 | from_secret: docker-registry-pw 220 | cache_from: "svedrin/meshping:latest-amd64" 221 | 222 | trigger: 223 | event: tag 224 | 225 | depends_on: 226 | - tests 227 | 228 | --- 229 | kind: pipeline 230 | name: linux-armv7l-tagged 231 | 232 | platform: 233 | arch: arm 234 | os: linux 235 | 236 | steps: 237 | - name: set version tag 238 | image: alpine:3.20 239 | commands: 240 | - echo -n "${DRONE_TAG}-armv7l" > .tags 241 | 242 | - name: build docker image 243 | image: plugins/docker 244 | settings: 245 | repo: svedrin/meshping 246 | username: svedrin 247 | password: 248 | from_secret: docker-registry-pw 249 | cache_from: "svedrin/meshping:latest-armv7l" 250 | 251 | trigger: 252 | event: tag 253 | 254 | depends_on: 255 | - tests 256 | 257 | 258 | --- 259 | kind: pipeline 260 | type: docker 261 | name: manifest-tagged 262 | 263 | steps: 264 | - name: publish 265 | image: plugins/manifest:1.2 266 | settings: 267 | ignore_missing: true 268 | spec: docker-manifest-tagged.tmpl 269 | username: svedrin 270 | password: 271 | from_secret: docker-registry-pw 272 | 273 | trigger: 274 | event: tag 275 | 276 | depends_on: 277 | - linux-amd64-tagged 278 | - linux-armv7l-tagged 279 | 280 | 281 | --- 282 | kind: signature 283 | hmac: f654337d3882eaf533b7e6fed6c083202fc53ef96eb734528a8c4ec0cebe4888 284 | 285 | ... 286 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | python-ping/ 2 | 3 | *.pyc 4 | 5 | oping.c 6 | oping.so 7 | build/ 8 | 9 | ui/node_modules/ 10 | db/ 11 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | extension-pkg-whitelist = netifaces 3 | 4 | [FORMAT] 5 | max-line-length=120 6 | 7 | [MESSAGES CONTROL] 8 | disable=E1101,E1102,E1103,C0111,C0103,W0613,W0108,W0212,R0903 9 | 10 | [DESIGN] 11 | max-public-methods=100 12 | 13 | [REPORTS] 14 | reports=y 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build oping-py 2 | 3 | FROM alpine:3.20 4 | 5 | RUN apk add --no-cache python3 python3-dev py3-pip musl-dev liboping-dev make gcc bash nodejs npm cython tzdata 6 | 7 | COPY ui/package*.json /opt/meshping/ui/ 8 | RUN cd /opt/meshping/ui && npm install 9 | 10 | WORKDIR /opt/meshping 11 | COPY oping-py /opt/meshping/oping-py 12 | RUN cd /opt/meshping/oping-py && python3 setup.py build && python3 setup.py install 13 | 14 | # Build meshping 15 | 16 | FROM alpine:3.20 17 | 18 | RUN apk add --no-cache python3 py3-pip liboping bash py3-netifaces py3-pillow dumb-init ttf-dejavu py3-pandas tzdata plantuml openjdk8-jre 19 | 20 | COPY requirements.txt /opt/meshping/requirements.txt 21 | RUN pip3 install --break-system-packages --no-cache-dir -r /opt/meshping/requirements.txt 22 | 23 | WORKDIR /opt/meshping 24 | COPY --from=0 /opt/meshping/ui/node_modules/jquery/LICENSE.txt /opt/meshping/ui/node_modules/jquery/ 25 | COPY --from=0 /opt/meshping/ui/node_modules/jquery/dist/jquery.slim.min.js /opt/meshping/ui/node_modules/jquery/dist/ 26 | COPY --from=0 /opt/meshping/ui/node_modules/bootstrap/LICENSE /opt/meshping/ui/node_modules/bootstrap/ 27 | COPY --from=0 /opt/meshping/ui/node_modules/bootstrap/dist/css/bootstrap.min.css /opt/meshping/ui/node_modules/bootstrap/dist/css/ 28 | COPY --from=0 /opt/meshping/ui/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js /opt/meshping/ui/node_modules/bootstrap/dist/js/ 29 | COPY --from=0 /opt/meshping/ui/node_modules/bootstrap-icons/LICENSE /opt/meshping/ui/node_modules/bootstrap-icons/ 30 | COPY --from=0 /opt/meshping/ui/node_modules/bootstrap-icons/icons/ /opt/meshping/ui/node_modules/bootstrap-icons/icons/ 31 | COPY --from=0 /opt/meshping/ui/node_modules/vue/LICENSE /opt/meshping/ui/node_modules/vue/ 32 | COPY --from=0 /opt/meshping/ui/node_modules/vue/dist/vue.min.js /opt/meshping/ui/node_modules/vue/dist/ 33 | COPY --from=0 /opt/meshping/ui/node_modules/vue-resource/LICENSE /opt/meshping/ui/node_modules/vue-resource/ 34 | COPY --from=0 /opt/meshping/ui/node_modules/vue-resource/dist/vue-resource.min.js /opt/meshping/ui/node_modules/vue-resource/dist/ 35 | COPY --from=0 /usr/lib/python3.12/site-packages/*/oping.*.so /usr/lib/python3.12/site-packages 36 | COPY src /opt/meshping/src 37 | COPY ui/src /opt/meshping/ui/src 38 | 39 | VOLUME /opt/meshping/db 40 | 41 | ENTRYPOINT ["dumb-init", "--"] 42 | ENV PYTHONPATH=/opt/meshping/src 43 | CMD ["hypercorn", "--reload", "-k", "trio", "-b", "[::]:9922", "meshping:app"] 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Michael Ziegler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # meshping 2 | 3 | Pings a number of targets at once, collecting their response times in histograms. Deploy at strategic points in your network to detect weak links and gain insight to your network's topology. 4 | 5 | I held a talk at the Chemnitzer Linuxtage 2025, the slides can be found here: [2025-03_Vortrag-CLT-Meshping.pdf](examples/2025-03_Vortrag-CLT-Meshping.pdf). 6 | 7 | ## Features 8 | 9 | * Graphs show latencies as they are, not aggregated into an average. 10 | * Runs traceroute to show the hops between your monitoring node and the targets. 11 | * Uses traced routes to draw a map of your network, rendered as an SVG. 12 | * Performs [Path MTU discovery](https://en.wikipedia.org/wiki/Path_MTU_Discovery) for each hop along the route, so you can see where MTUs get smaller. 13 | * Detects and displays routing loops. 14 | * Shows [AS](https://en.wikipedia.org/wiki/Autonomous_system_(Internet)) info about the hops along the route. 15 | * Shows where exactly an outage occurs by coloring nodes in the network map red, even if those aren't targets. 16 | * Multiple targets can be rendered in a single graph for comparison. 17 | * Meshping instances can be [peered](#wide-distribution-peering) with one another and will then ping the same targets. 18 | * Scrapeable by [Prometheus](prometheus.io). 19 | * Targets can be added and removed on-the-fly, without restarting or reloading anything. 20 | * IPv6 supported. 21 | * Docker images: https://hub.docker.com/r/svedrin/meshping 22 | 23 | 24 | # UI 25 | 26 | Here's a screenshot of the main Web UI: 27 | 28 | ![web_ui](examples/ui.png) 29 | 30 | There's a mobile-friendly version too: 31 | 32 | ![web_ui-mobile](examples/ui-mobile.png) 33 | 34 | Loop detection looks like this: 35 | 36 | ![web_ui-loop-detected](examples/ui-loop-detected.png) 37 | 38 | Here's a view of the traced route, including the Path MTU up to each hop and the AS information: 39 | 40 | ![web_ui-traceroute](examples/ui-traceroute.png) 41 | 42 | Last but not least, here's an example for a network map, also including AS information: 43 | 44 | ![web_ui-netmap](examples/ui-netmap.png) 45 | 46 | When a target stops responding, nodes in the network map are colored to show where an outage might have occurred (I faked this one for demo purposes by dropping their responses using iptables): 47 | 48 | ![web_ui-netmap](examples/ui-netmap-colored.png) 49 | 50 | # Heatmaps 51 | 52 | Meshping can render heatmaps from the pings measured over the last few (by default, three) days. They look like this: 53 | 54 | ![built-in heatmap](examples/heatmap4.png) 55 | 56 | You can nicely see that, while most of the pings are between 11 and 16ms, a significant number take around 22ms. This indicates that under some conditions, packets may take a different route to the recipient, or the recipient may just take longer to send a reply. 57 | 58 | Here's one we recently used to debug connectivity issues a customer was having in one of our datacenters. One of the firewalls had gone bonkers, occasionally delaying packets. The average ping had gone up to 7ms, which maybe would not have looked all that bad, but the histogram very clearly shows that something's wrong: 59 | 60 | ![datacenter heatmap](examples/heatmap6.png) 61 | 62 | This was actually bad enough that RDP sessions would drop and file shares would become unavailable. Being able to clearly see the issue (and also verify the fix!) was invaluable. 63 | 64 | Here's the heatmap for a WiFi point-to-point link that spans a few kilometers: 65 | 66 | ![wifiwifi](examples/heatmap5.png) 67 | 68 | Most pings are fine, but there does appear to be a fair bit of disturbance - maybe there's a tree in the way. 69 | 70 | The time span covered by these can be configured by setting the `MESHPING_HISTOGRAM_DAYS` environment variable to a value other than `3`. 71 | 72 | 73 | # Deploying 74 | 75 | Deploying meshping is easiest using [`docker-compose`](https://docs.docker.com/compose/), with the 76 | [docker-compose.yaml](https://github.com/Svedrin/meshping/blob/master/examples/docker-compose.yaml) file from this repo. 77 | This will deploy meshping, along with a [Watchtower](https://hub.docker.com/r/containrrr/watchtower) instance that keeps 78 | Meshping up-to-date. It can be deployed as-is by adding a Stack through Portainer, or using `docker-compose`: 79 | 80 | mkdir meshping 81 | cd meshping 82 | wget https://raw.githubusercontent.com/Svedrin/meshping/master/examples/docker-compose.yaml 83 | docker compose up --detach 84 | 85 | Meshping should now be reachable at `http://:9922`. 86 | 87 | ## Running on a Raspberry Pi 88 | 89 | A Docker image for the Raspberry Pi 4 is also available. To use it, you need to have: 90 | 91 | * Docker version 19.03.9 or newer, and 92 | * libseccomp version 2.4.2 or newer. 93 | 94 | See [issue #30](https://github.com/Svedrin/meshping/issues/30#issuecomment-872066856) for details and instructions on how you can check if you have them, and provide them if not. 95 | 96 | ## Running on Windows 97 | 98 | Running meshping on Windows is easiest using Docker Desktop and the WSL2 backend. Do use this method, you need to have WSL2 and Docker installed. Then run these commands in a PowerShell terminal: 99 | 100 | ``` 101 | docker volume create meshping-db 102 | docker run -d --name meshping -p 9922:9922 --restart=always --hostname (Get-Item env:\Computername).Value -v meshping-db:/opt/meshping/db svedrin/meshping 103 | ``` 104 | 105 | This will start MeshPing and configure it to start automatically and show the correct hostname in the UI. 106 | 107 | 108 | # Distributed Meshping 109 | 110 | If you have set up multiple Meshping instances, you can have them exchange targets via peering. To do this, set the 111 | `MESHPING_PEERS` env var in each instance to point to each other. That way, they will exchange target lists regularly, 112 | and you will be able to retrieve statistics from both sides to see how your links are doing. 113 | 114 | 115 | # Latency Analysis 116 | 117 | When doing mathematical analyses on measurements, monitoring tools usually apply calculations based on averages and standard deviations. 118 | With latency data however, these methods yield unsatisfactory results. 119 | 120 | Let's take another look at this heatmap: 121 | 122 | ![built-in heatmap](examples/heatmap4.png) 123 | 124 | You'll see that, while most of the pings are between 11 and 16ms, a significant number take around 22ms. 125 | The average as calculated by meshping is 16ms, and the standard deviation is probably somewhere around 2ms. 126 | 127 | Now suppose you're trying to formulate an alerting rule based on those numbers. Say you'd want to be alerted whenever ping results differ 128 | from the average for more than two standard deviations. This means that data points smaller than 12ms or greater than 20ms would potentially 129 | trigger an alert. Since this would probably be a bit noisy, let's assume you'll only alert after a bunch of those arrive over a given time span. 130 | 131 | But as you can see from the graph, there's a significant number of pings that just take 22ms, for whatever reason. Since this is a WAN link 132 | that we don't have control over, we won't be able to fix it - we just have to take it for what it is. Now how do you express that in terms 133 | of averages and standard deviations? The answer is: you can't, because the data does not follow a 134 | [Normal distribution](https://en.wikipedia.org/wiki/Normal_distribution). Instead, this signal consists of _two separate_ signals (because 135 | it seems that these packets can take two different routes, resulting in a small difference in latency), each of which _can_ be described using 136 | those terms: one lives at 13±3ms, the other one at 22±2ms. To conduct a meaningful analysis of this data, you can't just approach it as if 137 | it consisted of one single signal. 138 | 139 | I'd like to start looking for a solution to this. At the moment I'm focusing on getting the graphs to a point that they visualize this, and I'm 140 | pretty satisfied with the heatmaps as they are currently. Next, I'll probably look into 141 | [modality detection](https://www.brendangregg.com/FrequencyTrails/modes.html) and finding ways to extract patterns 142 | out of the data that I can then use to draw conclusions from. 143 | 144 | Much of this is derived from [Theo Schlossnagle's talk called "Monitoring: The Math Behind Bad Behavior"](https://www.youtube.com/watch?v=NThyvGkU6xY), go check it out if you want to know more. 145 | 146 | 147 | # Prometheus 148 | 149 | Meshping provides a `/metrics` endpoint that is meant to be scraped by Prometheus. You can run queries on the data for things such as: 150 | 151 | * loss rate in %: `rate(meshping_lost{target="$target"}[2m]) / rate(meshping_sent[2m]) * 100` 152 | * quantiles: `histogram_quantile(0.95, rate(meshping_pings_bucket{target="$target"}[2m]))` 153 | * averages: `rate(meshping_pings_sum{target="10.5.1.2"}[2m]) / rate(meshping_pings_count[2m])` 154 | 155 | 156 | # Configuration options 157 | 158 | Meshping is configured through environment variables. These exist: 159 | 160 | * `MESHPING_PING_INTERVAL`: Interval between sent ICMP echo request packages for each target in seconds (default: 30) 161 | * `MESHPING_PING_TIMEOUT`: Ping timeout in seconds (default: 5). 162 | * `MESHPING_TRACEROUTE_INTERVAL`: Seconds between traceroute runs (default: 900) 163 | * `MESHPING_PEERS`: Comma-separated list of other Meshping instances to peer with (only `ip:port`, no URLs). 164 | * `MESHPING_HISTOGRAM_DAYS`: How many days of data to keep in the histograms (default: 3). 165 | * `MESHPING_DATABASE_PATH`: Path to the underlying SQLite database file, either relative to the main script invocation location or absolute (default: `db`) 166 | * `MESHPING_PEERING_INTERVAL`: Interval between peer communication attempts in seconds (default: 30) 167 | * `MESHPING_DEV`: Set to `true` to allow automatic UI template reloading (default: `false`) 168 | 169 | Following variables are deprecated and thus their use is discouraged: 170 | 171 | * `MESHPING_PROMETHEUS_URL`: Not configurable at the moment. See [Prometheus](#prometheus) 172 | * `MESHPING_PROMETHEUS_QUERY`: Not configurable at the moment. See [Prometheus](#prometheus) 173 | * `MESHPING_REDIS_HOST`: Not configurable at the moment 174 | 175 | 176 | # Dev build 177 | 178 | Building locally for development is easiest by running the `./run-dev.sh` script. It will build the container and start up Meshping. 179 | 180 | 181 | # Known issues 182 | 183 | * If you're running meshping behind nginx, be sure to set `proxy_http_version 1.1;` or it'll be unbearably slow. 184 | * Only [scores 11/12 in the Joel Test](https://github.com/Svedrin/meshping/issues/57). 185 | 186 | 187 | # Stargazers over time 188 | 189 | [![Stargazers over time](https://starchart.cc/Svedrin/meshping.svg?variant=adaptive)](https://starchart.cc/Svedrin/meshping) 190 | 191 | # Who do I talk to? 192 | 193 | * First and foremost: Feel free to open an [issue](https://github.com/Svedrin/meshping/issues/new) in this repository. :) 194 | * If you'd like to get in touch, you can send me an [email](mailto:i.am@svedr.in). 195 | * I also regularly hang out at the [Linux User Group in Fulda](https://lugfd.de/). 196 | -------------------------------------------------------------------------------- /docker-manifest-latest.tmpl: -------------------------------------------------------------------------------- 1 | {{#equal build.branch "master"}} 2 | 3 | image: svedrin/meshping:latest 4 | 5 | manifests: 6 | 7 | - image: svedrin/meshping:latest-amd64 8 | platform: 9 | architecture: amd64 10 | os: linux 11 | 12 | - image: svedrin/meshping:latest-armv7l 13 | platform: 14 | architecture: arm 15 | os: linux 16 | variant: v7 17 | 18 | - image: svedrin/meshping:latest-arm64 19 | platform: 20 | architecture: arm64 21 | os: linux 22 | 23 | {{/equal}} 24 | {{#equal build.branch "staging"}} 25 | 26 | image: svedrin/meshping:{{build.branch}} 27 | 28 | manifests: 29 | 30 | - image: svedrin/meshping:{{build.branch}}-amd64 31 | platform: 32 | architecture: amd64 33 | os: linux 34 | 35 | - image: svedrin/meshping:{{build.branch}}-armv7l 36 | platform: 37 | architecture: arm 38 | os: linux 39 | variant: v7 40 | 41 | - image: svedrin/meshping:{{build.branch}}-arm64 42 | platform: 43 | architecture: arm64 44 | os: linux 45 | variant: v8 46 | 47 | {{/equal}} 48 | -------------------------------------------------------------------------------- /docker-manifest-tagged.tmpl: -------------------------------------------------------------------------------- 1 | image: svedrin/meshping:{{build.tag}} 2 | 3 | {{#if build.tags}} 4 | tags: 5 | {{#each build.tags}} 6 | - {{this}} 7 | {{/each}} 8 | {{/if}} 9 | 10 | manifests: 11 | 12 | - image: svedrin/meshping:{{build.tag}}-amd64 13 | platform: 14 | architecture: amd64 15 | os: linux 16 | 17 | - image: svedrin/meshping:{{build.tag}}-armv7l 18 | platform: 19 | architecture: arm 20 | os: linux 21 | variant: v7 22 | -------------------------------------------------------------------------------- /examples/2025-03_Vortrag-CLT-Meshping.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/2025-03_Vortrag-CLT-Meshping.pdf -------------------------------------------------------------------------------- /examples/dhcpd.conf: -------------------------------------------------------------------------------- 1 | # This is an example configuration that shows how to add hooks to ISC DHCPd that automatically add and remove hosts 2 | # from meshping when leases are handed out or removed. 3 | 4 | subnet 192.168.0.0 netmask 255.255.255.0 { 5 | on commit { 6 | set clip = binary-to-ascii(10, 8, ".", leased-address); 7 | execute("/opt/meshping/cli.py", "-a", concat(option host-name, ".example.com.", "@", clip)); 8 | } 9 | 10 | on release { 11 | set clip = binary-to-ascii(10, 8, ".", leased-address); 12 | execute("/opt/meshping/cli.py", "-d", clip); 13 | } 14 | 15 | on expiry { 16 | set clip = binary-to-ascii(10, 8, ".", leased-address); 17 | execute("/opt/meshping/cli.py", "-d", clip); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | volumes: 4 | meshping-data: {} 5 | 6 | services: 7 | meshping: 8 | image: "svedrin/meshping:latest" 9 | network_mode: "host" 10 | restart: always 11 | volumes: 12 | - "meshping-data:/opt/meshping/db" 13 | labels: 14 | "com.centurylinklabs.watchtower.enable": "true" 15 | # If you want to add other Meshping instances to peer with, uncomment this: 16 | #environment: 17 | # MESHPING_PEERS: 10.10.10.1:9922,10.10.20.1:9922 18 | 19 | watchtower: 20 | image: "containrrr/watchtower:latest" 21 | command: "--label-enable --cleanup --debug --interval 60" 22 | restart: always 23 | volumes: 24 | - "/var/run/docker.sock:/var/run/docker.sock" 25 | -------------------------------------------------------------------------------- /examples/grafana.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_PROMETHEUS", 5 | "label": "Prometheus", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "prometheus", 9 | "pluginName": "Prometheus" 10 | } 11 | ], 12 | "__requires": [ 13 | { 14 | "type": "grafana", 15 | "id": "grafana", 16 | "name": "Grafana", 17 | "version": "6.3.5" 18 | }, 19 | { 20 | "type": "panel", 21 | "id": "heatmap", 22 | "name": "Heatmap", 23 | "version": "" 24 | }, 25 | { 26 | "type": "datasource", 27 | "id": "prometheus", 28 | "name": "Prometheus", 29 | "version": "1.0.0" 30 | } 31 | ], 32 | "annotations": { 33 | "list": [ 34 | { 35 | "builtIn": 1, 36 | "datasource": "-- Grafana --", 37 | "enable": true, 38 | "hide": true, 39 | "iconColor": "rgba(0, 211, 255, 1)", 40 | "name": "Annotations & Alerts", 41 | "type": "dashboard" 42 | } 43 | ] 44 | }, 45 | "editable": true, 46 | "gnetId": null, 47 | "graphTooltip": 0, 48 | "id": null, 49 | "iteration": 1575568444654, 50 | "links": [], 51 | "panels": [ 52 | { 53 | "cards": { 54 | "cardPadding": null, 55 | "cardRound": null 56 | }, 57 | "color": { 58 | "cardColor": "#0a50a1", 59 | "colorScale": "sqrt", 60 | "colorScheme": "interpolateBlues", 61 | "exponent": 0.5, 62 | "mode": "opacity" 63 | }, 64 | "dataFormat": "tsbuckets", 65 | "datasource": "${DS_PROMETHEUS}", 66 | "gridPos": { 67 | "h": 10, 68 | "w": 24, 69 | "x": 0, 70 | "y": 0 71 | }, 72 | "heatmap": {}, 73 | "hideZeroBuckets": false, 74 | "highlightCards": true, 75 | "id": 2, 76 | "legend": { 77 | "show": true 78 | }, 79 | "links": [], 80 | "options": {}, 81 | "reverseYBuckets": false, 82 | "targets": [ 83 | { 84 | "expr": "increase(meshping_pings_bucket{target=\"$target\"}[$interval])", 85 | "format": "heatmap", 86 | "interval": "$interval", 87 | "intervalFactor": 2, 88 | "legendFormat": "{{ le }}", 89 | "refId": "A" 90 | } 91 | ], 92 | "title": "Heat Map", 93 | "tooltip": { 94 | "show": true, 95 | "showHistogram": true 96 | }, 97 | "tooltipDecimals": null, 98 | "type": "heatmap", 99 | "xAxis": { 100 | "show": true 101 | }, 102 | "xBucketNumber": null, 103 | "xBucketSize": null, 104 | "yAxis": { 105 | "decimals": null, 106 | "format": "ms", 107 | "logBase": 1, 108 | "max": null, 109 | "min": null, 110 | "show": true, 111 | "splitFactor": null 112 | }, 113 | "yBucketBound": "auto", 114 | "yBucketNumber": null, 115 | "yBucketSize": null 116 | } 117 | ], 118 | "refresh": "5m", 119 | "schemaVersion": 19, 120 | "style": "dark", 121 | "tags": [], 122 | "templating": { 123 | "list": [ 124 | { 125 | "allValue": null, 126 | "current": {}, 127 | "datasource": "${DS_PROMETHEUS}", 128 | "definition": "", 129 | "hide": 0, 130 | "includeAll": false, 131 | "label": null, 132 | "multi": false, 133 | "name": "target", 134 | "options": [], 135 | "query": "meshping_min", 136 | "refresh": 1, 137 | "regex": "/.*target=\"(.*)\".*/", 138 | "skipUrlSync": false, 139 | "sort": 3, 140 | "tagValuesQuery": "", 141 | "tags": [], 142 | "tagsQuery": "", 143 | "type": "query", 144 | "useTags": false 145 | }, 146 | { 147 | "auto": true, 148 | "auto_count": 30, 149 | "auto_min": "10s", 150 | "current": { 151 | "text": "auto", 152 | "value": "$__auto_interval_interval" 153 | }, 154 | "hide": 0, 155 | "label": null, 156 | "name": "interval", 157 | "options": [ 158 | { 159 | "selected": true, 160 | "text": "auto", 161 | "value": "$__auto_interval_interval" 162 | }, 163 | { 164 | "selected": false, 165 | "text": "1m", 166 | "value": "1m" 167 | }, 168 | { 169 | "selected": false, 170 | "text": "10m", 171 | "value": "10m" 172 | }, 173 | { 174 | "selected": false, 175 | "text": "30m", 176 | "value": "30m" 177 | }, 178 | { 179 | "selected": false, 180 | "text": "1h", 181 | "value": "1h" 182 | }, 183 | { 184 | "selected": false, 185 | "text": "6h", 186 | "value": "6h" 187 | }, 188 | { 189 | "selected": false, 190 | "text": "12h", 191 | "value": "12h" 192 | }, 193 | { 194 | "selected": false, 195 | "text": "1d", 196 | "value": "1d" 197 | }, 198 | { 199 | "selected": false, 200 | "text": "7d", 201 | "value": "7d" 202 | }, 203 | { 204 | "selected": false, 205 | "text": "14d", 206 | "value": "14d" 207 | }, 208 | { 209 | "selected": false, 210 | "text": "30d", 211 | "value": "30d" 212 | } 213 | ], 214 | "query": "1m,10m,30m,1h,6h,12h,1d,7d,14d,30d", 215 | "refresh": 2, 216 | "skipUrlSync": false, 217 | "type": "interval" 218 | } 219 | ] 220 | }, 221 | "time": { 222 | "from": "now-1h", 223 | "to": "now" 224 | }, 225 | "timepicker": { 226 | "refresh_intervals": [ 227 | "5s", 228 | "10s", 229 | "30s", 230 | "1m", 231 | "5m", 232 | "15m", 233 | "30m", 234 | "1h", 235 | "2h", 236 | "1d" 237 | ], 238 | "time_options": [ 239 | "5m", 240 | "15m", 241 | "1h", 242 | "6h", 243 | "12h", 244 | "24h", 245 | "2d", 246 | "7d", 247 | "30d" 248 | ] 249 | }, 250 | "timezone": "browser", 251 | "title": "Meshping", 252 | "uid": "000000004", 253 | "version": 3 254 | } 255 | -------------------------------------------------------------------------------- /examples/grafana_overview.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_PROMETHEUS", 5 | "label": "Prometheus", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "prometheus", 9 | "pluginName": "Prometheus" 10 | } 11 | ], 12 | "__requires": [ 13 | { 14 | "type": "grafana", 15 | "id": "grafana", 16 | "name": "Grafana", 17 | "version": "6.4.2" 18 | }, 19 | { 20 | "type": "panel", 21 | "id": "graph", 22 | "name": "Graph", 23 | "version": "" 24 | }, 25 | { 26 | "type": "datasource", 27 | "id": "prometheus", 28 | "name": "Prometheus", 29 | "version": "1.0.0" 30 | } 31 | ], 32 | "annotations": { 33 | "list": [ 34 | { 35 | "builtIn": 1, 36 | "datasource": "-- Grafana --", 37 | "enable": true, 38 | "hide": true, 39 | "iconColor": "rgba(0, 211, 255, 1)", 40 | "name": "Annotations & Alerts", 41 | "type": "dashboard" 42 | } 43 | ] 44 | }, 45 | "editable": true, 46 | "gnetId": null, 47 | "graphTooltip": 0, 48 | "id": null, 49 | "iteration": 1575598739410, 50 | "links": [], 51 | "panels": [ 52 | { 53 | "aliasColors": {}, 54 | "bars": false, 55 | "dashLength": 10, 56 | "dashes": false, 57 | "datasource": "${DS_PROMETHEUS}", 58 | "fill": 0, 59 | "fillGradient": 0, 60 | "gridPos": { 61 | "h": 8, 62 | "w": 24, 63 | "x": 0, 64 | "y": 0 65 | }, 66 | "id": 5, 67 | "legend": { 68 | "avg": false, 69 | "current": false, 70 | "max": false, 71 | "min": false, 72 | "show": true, 73 | "total": false, 74 | "values": false 75 | }, 76 | "lines": true, 77 | "linewidth": 1, 78 | "nullPointMode": "null", 79 | "options": { 80 | "dataLinks": [] 81 | }, 82 | "percentage": false, 83 | "pointradius": 2, 84 | "points": false, 85 | "renderer": "flot", 86 | "seriesOverrides": [], 87 | "spaceLength": 10, 88 | "stack": false, 89 | "steppedLine": false, 90 | "targets": [ 91 | { 92 | "expr": "sum(\n rate(meshping_pings_sum{target=~\"$target\",name=~\"$name\"}[$interval]) \n /\r\n rate(meshping_pings_count{target=~\"$target\",name=~\"$name\"}[$interval])\n) by (name)", 93 | "legendFormat": "{{name}}", 94 | "refId": "A" 95 | } 96 | ], 97 | "thresholds": [], 98 | "timeFrom": null, 99 | "timeRegions": [], 100 | "timeShift": null, 101 | "title": "Average ping", 102 | "tooltip": { 103 | "shared": true, 104 | "sort": 0, 105 | "value_type": "individual" 106 | }, 107 | "type": "graph", 108 | "xaxis": { 109 | "buckets": null, 110 | "mode": "time", 111 | "name": null, 112 | "show": true, 113 | "values": [] 114 | }, 115 | "yaxes": [ 116 | { 117 | "format": "ms", 118 | "label": null, 119 | "logBase": 1, 120 | "max": null, 121 | "min": null, 122 | "show": true 123 | }, 124 | { 125 | "format": "short", 126 | "label": null, 127 | "logBase": 1, 128 | "max": null, 129 | "min": null, 130 | "show": true 131 | } 132 | ], 133 | "yaxis": { 134 | "align": false, 135 | "alignLevel": null 136 | } 137 | }, 138 | { 139 | "aliasColors": {}, 140 | "bars": false, 141 | "dashLength": 10, 142 | "dashes": false, 143 | "datasource": "${DS_PROMETHEUS}", 144 | "fill": 0, 145 | "fillGradient": 0, 146 | "gridPos": { 147 | "h": 8, 148 | "w": 24, 149 | "x": 0, 150 | "y": 8 151 | }, 152 | "id": 3, 153 | "legend": { 154 | "alignAsTable": true, 155 | "avg": false, 156 | "current": false, 157 | "max": true, 158 | "min": true, 159 | "rightSide": true, 160 | "show": true, 161 | "total": false, 162 | "values": true 163 | }, 164 | "lines": true, 165 | "linewidth": 1, 166 | "links": [], 167 | "nullPointMode": "null", 168 | "options": { 169 | "dataLinks": [] 170 | }, 171 | "percentage": false, 172 | "pointradius": 2, 173 | "points": false, 174 | "renderer": "flot", 175 | "seriesOverrides": [ 176 | { 177 | "alias": "/recv/", 178 | "transform": "negative-Y" 179 | } 180 | ], 181 | "spaceLength": 10, 182 | "stack": false, 183 | "steppedLine": false, 184 | "targets": [ 185 | { 186 | "expr": "sum(increase(meshping_sent{target=~\"$target\",name=~\"$name\"}[$interval])) by (name, target)", 187 | "format": "time_series", 188 | "hide": false, 189 | "interval": "$interval", 190 | "intervalFactor": 1, 191 | "legendFormat": "{{name}}: {{target}} sent", 192 | "refId": "A" 193 | }, 194 | { 195 | "expr": "sum(increase(meshping_recv{target=~\"$target\",name=~\"$name\"}[$interval])) by (name, target)", 196 | "format": "time_series", 197 | "hide": false, 198 | "interval": "$interval", 199 | "intervalFactor": 1, 200 | "legendFormat": "{{name}}: {{target}} recv", 201 | "refId": "B" 202 | } 203 | ], 204 | "thresholds": [], 205 | "timeFrom": null, 206 | "timeRegions": [], 207 | "timeShift": null, 208 | "title": "Sent/received", 209 | "tooltip": { 210 | "shared": true, 211 | "sort": 0, 212 | "value_type": "individual" 213 | }, 214 | "type": "graph", 215 | "xaxis": { 216 | "buckets": null, 217 | "mode": "time", 218 | "name": null, 219 | "show": true, 220 | "values": [] 221 | }, 222 | "yaxes": [ 223 | { 224 | "format": "short", 225 | "label": null, 226 | "logBase": 1, 227 | "max": null, 228 | "min": null, 229 | "show": true 230 | }, 231 | { 232 | "format": "short", 233 | "label": null, 234 | "logBase": 1, 235 | "max": null, 236 | "min": null, 237 | "show": true 238 | } 239 | ], 240 | "yaxis": { 241 | "align": false, 242 | "alignLevel": null 243 | } 244 | }, 245 | { 246 | "aliasColors": {}, 247 | "bars": true, 248 | "dashLength": 10, 249 | "dashes": false, 250 | "datasource": "${DS_PROMETHEUS}", 251 | "fill": 0, 252 | "fillGradient": 0, 253 | "gridPos": { 254 | "h": 8, 255 | "w": 24, 256 | "x": 0, 257 | "y": 16 258 | }, 259 | "id": 8, 260 | "legend": { 261 | "alignAsTable": true, 262 | "avg": true, 263 | "current": false, 264 | "hideEmpty": true, 265 | "hideZero": true, 266 | "max": false, 267 | "min": false, 268 | "rightSide": true, 269 | "show": true, 270 | "total": true, 271 | "values": true 272 | }, 273 | "lines": false, 274 | "linewidth": 1, 275 | "links": [], 276 | "nullPointMode": "null", 277 | "options": { 278 | "dataLinks": [] 279 | }, 280 | "percentage": false, 281 | "pointradius": 2, 282 | "points": false, 283 | "renderer": "flot", 284 | "seriesOverrides": [], 285 | "spaceLength": 10, 286 | "stack": false, 287 | "steppedLine": false, 288 | "targets": [ 289 | { 290 | "expr": "delta(meshping_lost{target=~\"$target\",name=~\"$name\"}[$interval])", 291 | "format": "heatmap", 292 | "hide": false, 293 | "interval": "$interval", 294 | "intervalFactor": 2, 295 | "legendFormat": "{{name}}: {{target}}", 296 | "refId": "A" 297 | } 298 | ], 299 | "thresholds": [], 300 | "timeFrom": null, 301 | "timeRegions": [], 302 | "timeShift": null, 303 | "title": "Packet loss", 304 | "tooltip": { 305 | "shared": true, 306 | "sort": 0, 307 | "value_type": "individual" 308 | }, 309 | "type": "graph", 310 | "xaxis": { 311 | "buckets": null, 312 | "mode": "time", 313 | "name": null, 314 | "show": true, 315 | "values": [] 316 | }, 317 | "yaxes": [ 318 | { 319 | "format": "short", 320 | "label": null, 321 | "logBase": 1, 322 | "max": null, 323 | "min": null, 324 | "show": true 325 | }, 326 | { 327 | "format": "short", 328 | "label": null, 329 | "logBase": 1, 330 | "max": null, 331 | "min": null, 332 | "show": true 333 | } 334 | ], 335 | "yaxis": { 336 | "align": false, 337 | "alignLevel": null 338 | } 339 | } 340 | ], 341 | "refresh": "5m", 342 | "schemaVersion": 20, 343 | "style": "dark", 344 | "tags": [], 345 | "templating": { 346 | "list": [ 347 | { 348 | "allValue": null, 349 | "current": {}, 350 | "datasource": "${DS_PROMETHEUS}", 351 | "definition": "label_values(meshping_min,target)", 352 | "hide": 0, 353 | "includeAll": true, 354 | "label": null, 355 | "multi": true, 356 | "name": "target", 357 | "options": [], 358 | "query": "label_values(meshping_min,target)", 359 | "refresh": 2, 360 | "regex": "", 361 | "skipUrlSync": false, 362 | "sort": 3, 363 | "tagValuesQuery": "", 364 | "tags": [], 365 | "tagsQuery": "", 366 | "type": "query", 367 | "useTags": false 368 | }, 369 | { 370 | "auto": true, 371 | "auto_count": 30, 372 | "auto_min": "10s", 373 | "current": { 374 | "text": "auto", 375 | "value": "$__auto_interval_interval" 376 | }, 377 | "hide": 0, 378 | "label": null, 379 | "name": "interval", 380 | "options": [ 381 | { 382 | "selected": true, 383 | "text": "auto", 384 | "value": "$__auto_interval_interval" 385 | }, 386 | { 387 | "selected": false, 388 | "text": "5s", 389 | "value": "5s" 390 | }, 391 | { 392 | "selected": false, 393 | "text": "30s", 394 | "value": "30s" 395 | }, 396 | { 397 | "selected": false, 398 | "text": "1m", 399 | "value": "1m" 400 | }, 401 | { 402 | "selected": false, 403 | "text": "10m", 404 | "value": "10m" 405 | }, 406 | { 407 | "selected": false, 408 | "text": "30m", 409 | "value": "30m" 410 | }, 411 | { 412 | "selected": false, 413 | "text": "1h", 414 | "value": "1h" 415 | }, 416 | { 417 | "selected": false, 418 | "text": "6h", 419 | "value": "6h" 420 | }, 421 | { 422 | "selected": false, 423 | "text": "12h", 424 | "value": "12h" 425 | }, 426 | { 427 | "selected": false, 428 | "text": "1d", 429 | "value": "1d" 430 | }, 431 | { 432 | "selected": false, 433 | "text": "7d", 434 | "value": "7d" 435 | }, 436 | { 437 | "selected": false, 438 | "text": "14d", 439 | "value": "14d" 440 | }, 441 | { 442 | "selected": false, 443 | "text": "30d", 444 | "value": "30d" 445 | } 446 | ], 447 | "query": "5s,30s,1m,10m,30m,1h,6h,12h,1d,7d,14d,30d", 448 | "refresh": 2, 449 | "skipUrlSync": false, 450 | "type": "interval" 451 | }, 452 | { 453 | "allValue": null, 454 | "current": {}, 455 | "datasource": "${DS_PROMETHEUS}", 456 | "definition": "label_values(meshping_min,name)", 457 | "hide": 0, 458 | "includeAll": true, 459 | "label": null, 460 | "multi": true, 461 | "name": "name", 462 | "options": [], 463 | "query": "label_values(meshping_min,name)", 464 | "refresh": 2, 465 | "regex": "", 466 | "skipUrlSync": false, 467 | "sort": 5, 468 | "tagValuesQuery": "", 469 | "tags": [], 470 | "tagsQuery": "", 471 | "type": "query", 472 | "useTags": false 473 | } 474 | ] 475 | }, 476 | "time": { 477 | "from": "now-12h", 478 | "to": "now" 479 | }, 480 | "timepicker": { 481 | "refresh_intervals": [ 482 | "5s", 483 | "10s", 484 | "30s", 485 | "1m", 486 | "5m", 487 | "15m", 488 | "30m", 489 | "1h", 490 | "2h", 491 | "1d" 492 | ], 493 | "time_options": [ 494 | "5m", 495 | "15m", 496 | "1h", 497 | "6h", 498 | "12h", 499 | "24h", 500 | "2d", 501 | "7d", 502 | "30d" 503 | ] 504 | }, 505 | "timezone": "browser", 506 | "title": "Meshping overview", 507 | "uid": "EkvvHTaWk", 508 | "version": 12 509 | } 510 | -------------------------------------------------------------------------------- /examples/heatmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/heatmap.png -------------------------------------------------------------------------------- /examples/heatmap4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/heatmap4.png -------------------------------------------------------------------------------- /examples/heatmap5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/heatmap5.png -------------------------------------------------------------------------------- /examples/heatmap6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/heatmap6.png -------------------------------------------------------------------------------- /examples/ui-loop-detected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/ui-loop-detected.png -------------------------------------------------------------------------------- /examples/ui-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/ui-mobile.png -------------------------------------------------------------------------------- /examples/ui-netmap-colored.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/ui-netmap-colored.png -------------------------------------------------------------------------------- /examples/ui-netmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/ui-netmap.png -------------------------------------------------------------------------------- /examples/ui-traceroute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/ui-traceroute.png -------------------------------------------------------------------------------- /examples/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Svedrin/meshping/fada30b5d8cdf265d7c7855655451ce98ab180a3/examples/ui.png -------------------------------------------------------------------------------- /features/basics.feature: -------------------------------------------------------------------------------- 1 | Feature: Basic stuff. 2 | 3 | Scenario: Add a target through the UI endpoints. 4 | 5 | when we request a histogram for target "8.8.8.8" 6 | then we get a response with status code 404 7 | when we add a target of "8.8.8.8" named "google.com" 8 | then there exists a target of "8.8.8.8" named "google.com" 9 | when we wait 2 seconds 10 | when we request a histogram for target "8.8.8.8" 11 | then we get a response with status code 200 12 | when we delete a target of "8.8.8.8" 13 | then there exists no target of "8.8.8.8" 14 | 15 | Scenario: Same thing through the peering endpoints. 16 | 17 | when we request a histogram for target "1.1.1.1" 18 | then we get a response with status code 404 19 | when a peer sends us a target of "1.1.1.1" named "cloudflare.com" 20 | then there exists a target of "1.1.1.1" named "cloudflare.com" 21 | when we wait 2 seconds 22 | when we request a histogram for target "1.1.1.1" 23 | then we get a response with status code 200 24 | 25 | Scenario: Add a target through the UI endpoints and see that it gets dist'ed to peers. 26 | 27 | These targets are NOT foreign, thus shall be distributed. 28 | 29 | when we request a histogram for target "1.2.3.4" 30 | then we get a response with status code 404 31 | when we add a target of "1.2.3.4" named "dummycorp.com" 32 | then there exists a target of "1.2.3.4" named "dummycorp.com" 33 | and we send a target of "1.2.3.4" named "dummycorp.com" to our peers 34 | 35 | Scenario: Add a target through the peering endpoints and see that it does NOT get dist'ed to peers. 36 | 37 | These targets are foreign, thus shall NOT be distributed. 38 | 39 | when we request a histogram for target "4.3.2.1" 40 | then we get a response with status code 404 41 | when a peer sends us a target of "4.3.2.1" named "othercorp.com" 42 | then there exists a target of "4.3.2.1" named "othercorp.com" 43 | and we do not send a target of "4.3.2.1" to our peers 44 | -------------------------------------------------------------------------------- /features/environment.py: -------------------------------------------------------------------------------- 1 | import json 2 | import threading 3 | 4 | from http.server import HTTPServer, BaseHTTPRequestHandler 5 | 6 | def before_all(context): 7 | context.peer_queue = None 8 | 9 | class DummyPeeringHandler(BaseHTTPRequestHandler): 10 | def do_POST(self): 11 | content_length = int(self.headers['Content-Length']) 12 | body = self.rfile.read(content_length) 13 | self.send_response(200) 14 | self.end_headers() 15 | if context.peer_queue is not None: 16 | context.peer_queue.put(json.loads(body)) 17 | 18 | httpd = HTTPServer(('0.0.0.0', 31337), DummyPeeringHandler) 19 | context.peerserv = threading.Thread(target=httpd.serve_forever, daemon=True) 20 | context.peerserv.start() 21 | -------------------------------------------------------------------------------- /features/requirements.txt: -------------------------------------------------------------------------------- 1 | behave 2 | requests 3 | -------------------------------------------------------------------------------- /features/steps/basics.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import json 4 | import requests 5 | 6 | from queue import Queue 7 | from time import sleep 8 | from behave import given, when, then 9 | 10 | @when('we wait {n:d} seconds') 11 | def step(context, n): 12 | sleep(n) 13 | 14 | @when('we add a target of "{address}" named "{name}"') 15 | def step(context, address, name): 16 | resp = requests.post( 17 | "http://meshping:9922/api/targets", 18 | data=json.dumps({ 19 | "target": "%s@%s" % (name, address) 20 | }), 21 | headers = { 22 | "Content-Type": "application/json" 23 | } 24 | ) 25 | resp.raise_for_status() 26 | assert resp.json()["success"] == True 27 | 28 | @when('we delete a target of "{address}"') 29 | def step(context, address): 30 | resp = requests.delete("http://meshping:9922/api/targets/%s" % address) 31 | resp.raise_for_status() 32 | assert resp.json()["success"] == True 33 | 34 | @then('there exists a target of "{address}" named "{name}"') 35 | def step(context, address, name): 36 | resp = requests.get("http://meshping:9922/api/targets") 37 | resp.raise_for_status() 38 | assert resp.json()["success"] == True 39 | for target in resp.json()["targets"]: 40 | if target["addr"] == address and target["name"] == name: 41 | break 42 | else: 43 | assert False, "target does not exist" 44 | 45 | @then('there exists no target of "{address}"') 46 | def step(context, address): 47 | resp = requests.get("http://meshping:9922/api/targets") 48 | resp.raise_for_status() 49 | assert resp.json()["success"] == True 50 | for target in resp.json()["targets"]: 51 | if target["addr"] == address and target["name"] == name: 52 | assert False, "target exists (it shouldn't)" 53 | 54 | @when('we request a histogram for target "{address}"') 55 | def step(context, address): 56 | context.resp = requests.get("http://meshping:9922/histogram/obsolete/%s.png" % address) 57 | 58 | @then('we get a response with status code {status:d}') 59 | def step(context, status): 60 | assert context.resp.status_code, status 61 | 62 | @when('a peer sends us a target of "{address}" named "{name}"') 63 | def step(context, address, name): 64 | resp = requests.post( 65 | "http://meshping:9922/peer", 66 | data=json.dumps({ 67 | "targets": [{ 68 | "addr": address, 69 | "name": name, 70 | "local": False, 71 | }] 72 | }), 73 | headers = { 74 | "Content-Type": "application/json" 75 | } 76 | ) 77 | resp.raise_for_status() 78 | assert resp.json()["success"] == True 79 | 80 | @then('we send a target of "{address}" named "{name}" to our peers') 81 | def step(context, address, name): 82 | context.peer_queue = Queue() 83 | from_peer = context.peer_queue.get() 84 | context.peer_queue = None 85 | for target in from_peer["targets"]: 86 | if target["addr"] == address and target["name"] == name: 87 | break 88 | else: 89 | assert False, "target does not exist" 90 | 91 | @then('we do not send a target of "{address}" to our peers') 92 | def step(context, address): 93 | context.peer_queue = Queue() 94 | from_peer = context.peer_queue.get() 95 | context.peer_queue = None 96 | for target in from_peer["targets"]: 97 | if target["addr"] == address: 98 | assert False, "target exists (it shouldn't)" 99 | -------------------------------------------------------------------------------- /oping-py/oping.pyx: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # kate: space-indent on; indent-width 4; replace-tabs on; hl python; 3 | # 4 | # Python binding for octo's oping library. 5 | 6 | from libc.stdint cimport uint8_t, uint16_t, uint32_t 7 | 8 | cdef extern from "oping.h": 9 | ctypedef struct pinghost: 10 | pass 11 | 12 | ctypedef struct pingobj: 13 | pass 14 | 15 | ctypedef pinghost pinghost_t 16 | ctypedef pinghost pingobj_iter_t 17 | ctypedef pingobj pingobj_t 18 | 19 | pingobj_t *ping_construct () 20 | void ping_destroy (pingobj_t *obj) 21 | 22 | int ping_setopt (pingobj_t *obj, int option, void *value) 23 | 24 | int ping_send (pingobj_t *obj) nogil 25 | 26 | int ping_host_add (pingobj_t *obj, const char *host) 27 | int ping_host_remove (pingobj_t *obj, const char *host) 28 | 29 | pingobj_iter_t *ping_iterator_get (pingobj_t *obj) 30 | pingobj_iter_t *ping_iterator_next (pingobj_iter_t *iter) 31 | 32 | int ping_iterator_get_info (pingobj_iter_t *iter, int info, 33 | void *buffer, size_t *buffer_len) 34 | 35 | const char *ping_get_error (pingobj_t *obj) 36 | 37 | void *ping_iterator_get_context (pingobj_iter_t *iter) 38 | void ping_iterator_set_context (pingobj_iter_t *iter, void *context) 39 | 40 | 41 | cdef PING_OPT_TIMEOUT = 0x01 42 | cdef PING_OPT_TTL = 0x02 43 | cdef PING_OPT_AF = 0x04 44 | cdef PING_OPT_DATA = 0x08 45 | cdef PING_OPT_SOURCE = 0x10 46 | cdef PING_OPT_DEVICE = 0x20 47 | cdef PING_OPT_QOS = 0x40 48 | 49 | cdef PING_DEF_TIMEOUT = 1.0 50 | cdef PING_DEF_TTL = 255 51 | 52 | cdef PING_INFO_HOSTNAME = 1 53 | cdef PING_INFO_ADDRESS = 2 54 | cdef PING_INFO_FAMILY = 3 55 | cdef PING_INFO_LATENCY = 4 56 | cdef PING_INFO_SEQUENCE = 5 57 | cdef PING_INFO_IDENT = 6 58 | cdef PING_INFO_DATA = 7 59 | cdef PING_INFO_USERNAME = 8 60 | cdef PING_INFO_DROPPED = 9 61 | cdef PING_INFO_RECV_TTL = 10 62 | cdef PING_INFO_RECV_QOS = 11 63 | 64 | 65 | class PingError(RuntimeError): 66 | pass 67 | 68 | 69 | cdef class PingObj: 70 | cdef pingobj_t* _c_pingobj 71 | 72 | default_timeout = PING_DEF_TIMEOUT 73 | default_ttl = PING_DEF_TTL 74 | 75 | def __cinit__(self): 76 | self._c_pingobj = ping_construct() 77 | if self._c_pingobj is NULL: 78 | raise MemoryError() 79 | 80 | def __dealloc__(self): 81 | if self._c_pingobj is not NULL: 82 | ping_destroy(self._c_pingobj) 83 | 84 | def send(self): 85 | cdef int ret 86 | with nogil: 87 | ret = ping_send(self._c_pingobj) 88 | if ret < 0: 89 | raise PingError(ping_get_error(self._c_pingobj)) 90 | return ret 91 | 92 | def add_host(self, char *host): 93 | if len(host) > 50: 94 | raise ValueError("name is too long (max 50 chars)") 95 | if ping_host_add(self._c_pingobj, host) < 0: 96 | raise PingError(ping_get_error(self._c_pingobj)) 97 | 98 | def remove_host(self, char *host): 99 | if ping_host_remove(self._c_pingobj, host) < 0: 100 | raise PingError(ping_get_error(self._c_pingobj)) 101 | 102 | def get_hosts(self): 103 | cdef pingobj_iter_t *iter 104 | 105 | cdef char hostname[51] 106 | cdef char hostaddr[40] 107 | cdef int family 108 | cdef double latency 109 | cdef uint32_t dropped 110 | cdef uint32_t seqnr 111 | cdef uint16_t ident 112 | cdef int recvttl 113 | cdef uint8_t recvqos 114 | 115 | cdef size_t buflen 116 | 117 | hosts = [] 118 | 119 | iter = ping_iterator_get(self._c_pingobj) 120 | while iter != NULL: 121 | buflen = sizeof(hostname) - 1 122 | ping_iterator_get_info(iter, PING_INFO_USERNAME, &hostname, &buflen) 123 | hostname[buflen] = 0 124 | 125 | buflen = sizeof(hostaddr) - 1 126 | ping_iterator_get_info(iter, PING_INFO_ADDRESS, &hostaddr, &buflen) 127 | hostaddr[buflen] = 0 128 | 129 | buflen = sizeof(family) 130 | ping_iterator_get_info(iter, PING_INFO_FAMILY, &family, &buflen) 131 | 132 | buflen = sizeof(latency) 133 | ping_iterator_get_info(iter, PING_INFO_LATENCY, &latency, &buflen) 134 | 135 | buflen = sizeof(dropped) 136 | ping_iterator_get_info(iter, PING_INFO_DROPPED, &dropped, &buflen) 137 | 138 | buflen = sizeof(seqnr) 139 | ping_iterator_get_info(iter, PING_INFO_SEQUENCE, &seqnr, &buflen) 140 | 141 | buflen = sizeof(ident) 142 | ping_iterator_get_info(iter, PING_INFO_IDENT, &ident, &buflen) 143 | 144 | buflen = sizeof(recvttl) 145 | ping_iterator_get_info(iter, PING_INFO_RECV_TTL, &recvttl, &buflen) 146 | 147 | buflen = sizeof(recvqos) 148 | ping_iterator_get_info(iter, PING_INFO_RECV_QOS, &recvqos, &buflen) 149 | 150 | hosts.append({ 151 | "name": hostname, 152 | "addr": hostaddr, 153 | "addrfam": family, 154 | "latency": latency, 155 | "dropped": dropped, 156 | "seqnr": seqnr, 157 | "ident": ident, 158 | "recvttl": recvttl, 159 | "recvqos": recvqos, 160 | }) 161 | 162 | iter = ping_iterator_next(iter) 163 | 164 | return hosts 165 | 166 | def set_timeout(self, double val): 167 | if ping_setopt(self._c_pingobj, PING_OPT_TIMEOUT, &val) < 0: 168 | raise PingError(ping_get_error(self._c_pingobj)) 169 | 170 | def set_ttl(self, int val): 171 | if ping_setopt(self._c_pingobj, PING_OPT_TTL, &val) < 0: 172 | raise PingError(ping_get_error(self._c_pingobj)) 173 | 174 | def set_af(self, int val): 175 | if ping_setopt(self._c_pingobj, PING_OPT_AF, &val) < 0: 176 | raise PingError(ping_get_error(self._c_pingobj)) 177 | 178 | def set_data(self, char *val): 179 | if ping_setopt(self._c_pingobj, PING_OPT_DATA, &val) < 0: 180 | raise PingError(ping_get_error(self._c_pingobj)) 181 | 182 | def set_source(self, char *val): 183 | if ping_setopt(self._c_pingobj, PING_OPT_SOURCE, &val) < 0: 184 | raise PingError(ping_get_error(self._c_pingobj)) 185 | 186 | def set_device(self, char *val): 187 | if ping_setopt(self._c_pingobj, PING_OPT_DEVICE, &val) < 0: 188 | raise PingError(ping_get_error(self._c_pingobj)) 189 | 190 | def set_qos(self, uint8_t val): 191 | if ping_setopt(self._c_pingobj, PING_OPT_QOS, &val) < 0: 192 | raise PingError(ping_get_error(self._c_pingobj)) 193 | -------------------------------------------------------------------------------- /oping-py/setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from distutils.extension import Extension 3 | from Cython.Build import cythonize 4 | 5 | setup( 6 | ext_modules = cythonize([ 7 | Extension("oping", ["oping.pyx"], libraries=["oping"]) 8 | ], language_level=3) 9 | ) 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | hypercorn==0.16.0 2 | Quart-Trio==0.11.1 3 | Quart==0.20.0 4 | httpx==0.26.0 5 | icmplib==3.0.4 6 | ipwhois>=1.2.0 7 | netifaces 8 | netaddr 9 | packaging 10 | -------------------------------------------------------------------------------- /run-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -u 5 | 6 | PROFILE=false 7 | 8 | if [ "${1:-}" = "--help" ]; then 9 | echo "Usage: $0 [--help|--profile|]" 10 | exit 0 11 | elif [ "${1:-}" = "--profile" ]; then 12 | PROFILE=true 13 | fi 14 | 15 | if [ "${1:-}" = "clean" ]; then 16 | docker image rm --no-prune meshping:latest-dev 17 | exit 0 18 | fi 19 | 20 | if [ -z "$(docker image ls -q meshping:latest-dev)" ]; then 21 | docker build --network host -t meshping:latest-dev . 22 | fi 23 | 24 | mkdir -p /tmp/statistico 25 | 26 | function parse_result () { 27 | if [ -e "/tmp/statistico/profile.bin" ]; then 28 | echo "Parsing results..." 29 | python3 \ 30 | -c 'import pstats; pstats.Stats("/tmp/statistico/profile.bin").sort_stats("cumulative").print_stats()' \ 31 | > /tmp/statistico/profile.txt 32 | cat /tmp/statistico/profile.txt 33 | echo "Results are available in /tmp/statistico/profile.txt." 34 | fi 35 | } 36 | 37 | trap parse_result exit 38 | 39 | 40 | if [ "$PROFILE" = "true" ]; then 41 | echo "Running meshping with profiling enabled. Hit ^c to stop." 42 | COMMAND="python3 -m cProfile -o /tmp/statistico/profile.bin src/meshping.py" 43 | else 44 | echo "Running meshping. Hit ^c to stop." 45 | COMMAND="$@" 46 | fi 47 | 48 | docker run --rm -it --net=host --hostname meshping \ 49 | -e MESHPING_DEV=true \ 50 | -e TZ=${TZ:-Europe/Berlin} \ 51 | -v /tmp/statistico:/tmp/statistico \ 52 | -v $PWD/db:/opt/meshping/db \ 53 | -v $PWD/src:/opt/meshping/src \ 54 | -v $PWD/ui/src:/opt/meshping/ui/src \ 55 | meshping:latest-dev \ 56 | $COMMAND 57 | -------------------------------------------------------------------------------- /src/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # pylint: disable=unused-variable 4 | 5 | import socket 6 | 7 | from subprocess import run as run_command 8 | from datetime import datetime 9 | from random import randint 10 | from io import BytesIO 11 | from quart import Response, render_template, request, jsonify, send_from_directory, send_file, abort 12 | 13 | import histodraw 14 | 15 | from ifaces import Ifaces4 16 | 17 | def add_api_views(app, mp): 18 | @app.route("/") 19 | async def index(): 20 | return await render_template( 21 | "index.html", 22 | Hostname=socket.gethostname(), 23 | ) 24 | 25 | @app.route("/metrics") 26 | async def metrics(): 27 | respdata = ['\n'.join([ 28 | '# HELP meshping_sent Sent pings', 29 | '# TYPE meshping_sent counter', 30 | '# HELP meshping_recv Received pongs', 31 | '# TYPE meshping_recv counter', 32 | '# HELP meshping_lost Lost pings (actual counter, not just sent - recv)', 33 | '# TYPE meshping_lost counter', 34 | '# HELP meshping_max max ping', 35 | '# TYPE meshping_max gauge', 36 | '# HELP meshping_min min ping', 37 | '# TYPE meshping_min gauge', 38 | '# HELP meshping_pings Pings bucketed by response time', 39 | '# TYPE meshping_pings histogram', 40 | ])] 41 | 42 | for target in mp.all_targets(): 43 | target_info = dict( 44 | target.statistics, 45 | addr = target.addr, 46 | name = target.name 47 | ) 48 | respdata.append('\n'.join([ 49 | 'meshping_sent{name="%(name)s",target="%(addr)s"} %(sent)d', 50 | 'meshping_recv{name="%(name)s",target="%(addr)s"} %(recv)d', 51 | 'meshping_lost{name="%(name)s",target="%(addr)s"} %(lost)d', 52 | ]) % target_info) 53 | 54 | if target_info["recv"]: 55 | respdata.append('\n'.join([ 56 | 'meshping_max{name="%(name)s",target="%(addr)s"} %(max).2f', 57 | 'meshping_min{name="%(name)s",target="%(addr)s"} %(min).2f', 58 | ]) % target_info) 59 | 60 | respdata.append('\n'.join([ 61 | 'meshping_pings_sum{name="%(name)s",target="%(addr)s"} %(sum)f', 62 | 'meshping_pings_count{name="%(name)s",target="%(addr)s"} %(recv)d', 63 | ]) % target_info) 64 | 65 | histogram = target.histogram.tail(1) 66 | count = 0 67 | for bucket in histogram.columns: 68 | if histogram[bucket][0] == 0: 69 | continue 70 | nextping = 2 ** ((bucket + 1) / 10.) 71 | count += histogram[bucket][0] 72 | respdata.append( 73 | 'meshping_pings_bucket{name="%(name)s",target="%(addr)s",le="%(le).2f"} %(count)d' % dict( 74 | addr = target.addr, 75 | count = count, 76 | le = nextping, 77 | name = target.name, 78 | ) 79 | ) 80 | respdata.append( 81 | 'meshping_pings_bucket{name="%(name)s",target="%(addr)s",le="+Inf"} %(count)d' % dict( 82 | addr = target.addr, 83 | count = count, 84 | name = target.name, 85 | ) 86 | ) 87 | 88 | return Response('\n'.join(respdata) + '\n', mimetype="text/plain") 89 | 90 | @app.route("/peer", methods=["POST"]) 91 | async def peer(): 92 | # Allows peers to POST a json structure such as this: 93 | # { 94 | # "targets": [ 95 | # { "name": "raspi", "addr": "192.168.0.123", "local": true }, 96 | # { "name": "google", "addr": "8.8.8.8", "local": false } 97 | # ] 98 | # } 99 | # The non-local targets will then be added to our target list 100 | # and stats will be returned for these targets (if known). 101 | # Local targets will only be added if they are also local to us. 102 | 103 | request_json = await request.get_json() 104 | 105 | if request_json is None: 106 | return "Please send content-type:application/json", 400 107 | 108 | if not isinstance(request_json.get("targets"), list): 109 | return "need targets as a list", 400 110 | 111 | stats = [] 112 | if4 = Ifaces4() 113 | 114 | for target in request_json["targets"]: 115 | if not isinstance(target, dict): 116 | return "targets must be dicts", 400 117 | if ( 118 | not target.get("name", "").strip() or 119 | not target.get("addr", "").strip() or 120 | not isinstance(target.get("local"), bool) 121 | ): 122 | return "required field missing in target", 400 123 | 124 | target["name"] = target["name"].strip() 125 | target["addr"] = target["addr"].strip() 126 | 127 | if if4.is_interface(target["addr"]): 128 | # no need to ping my own interfaces, ignore 129 | continue 130 | 131 | if target["local"] and not if4.is_local(target["addr"]): 132 | continue 133 | 134 | # See if we know this target already, otherwise create it. 135 | try: 136 | target = mp.get_target(target["addr"]) 137 | except LookupError: 138 | target_str = "%(name)s@%(addr)s" % target 139 | mp.add_target(target_str) 140 | target = mp.get_target(target["addr"]) 141 | target.set_is_foreign(True) 142 | stats.append(target.statistics) 143 | 144 | return jsonify(success=True, targets=stats) 145 | 146 | @app.route('/ui/') 147 | async def send_js(path): 148 | resp = await send_from_directory('ui', path) 149 | # Cache bust XXL 150 | resp.cache_control.no_cache = True 151 | resp.cache_control.no_store = True 152 | resp.cache_control.max_age = None 153 | resp.cache_control.must_revalidate = True 154 | return resp 155 | 156 | @app.route("/api/resolve/") 157 | async def resolve(name): 158 | try: 159 | return jsonify(success=True, addrs=[ 160 | info[4][0] 161 | for info in socket.getaddrinfo(name, 0, 0, socket.SOCK_STREAM) 162 | ]) 163 | except socket.gaierror as err: 164 | return jsonify(success=False, error=str(err)) 165 | 166 | @app.route("/api/targets", methods=["GET", "POST"]) 167 | async def targets(): 168 | if request.method == "GET": 169 | targets = [] 170 | 171 | for target in mp.all_targets(): 172 | target_stats = target.statistics 173 | succ = 0 174 | loss = 0 175 | if target_stats["sent"] > 0: 176 | succ = target_stats["recv"] / target_stats["sent"] * 100 177 | loss = (target_stats["sent"] - target_stats["recv"]) / target_stats["sent"] * 100 178 | targets.append( 179 | dict( 180 | target_stats, 181 | addr=target.addr, 182 | name=target.name, 183 | state=target.state, 184 | error=target.error, 185 | succ=succ, 186 | loss=loss, 187 | traceroute=target.traceroute, 188 | route_loop=target.route_loop, 189 | ) 190 | ) 191 | 192 | return jsonify(success=True, targets=targets) 193 | 194 | if request.method == "POST": 195 | request_json = await request.get_json() 196 | if "target" not in request_json: 197 | return "missing target", 400 198 | 199 | target = request_json["target"] 200 | added = [] 201 | 202 | if "@" not in target: 203 | try: 204 | addrinfo = socket.getaddrinfo(target, 0, 0, socket.SOCK_STREAM) 205 | except socket.gaierror as err: 206 | return jsonify(success=False, target=target, error=str(err)) 207 | 208 | for info in addrinfo: 209 | target_with_addr = "%s@%s" % (target, info[4][0]) 210 | mp.add_target(target_with_addr) 211 | added.append(target_with_addr) 212 | else: 213 | mp.add_target(target) 214 | added.append(target) 215 | 216 | return jsonify(success=True, targets=added) 217 | 218 | abort(400) 219 | 220 | @app.route("/api/targets/", methods=["PATCH", "PUT", "DELETE"]) 221 | async def edit_target(target): 222 | if request.method == "DELETE": 223 | mp.remove_target(target) 224 | return jsonify(success=True) 225 | 226 | return jsonify(success=False) 227 | 228 | @app.route("/api/stats", methods=["DELETE"]) 229 | async def clear_statistics(): 230 | mp.clear_statistics() 231 | return jsonify(success=True) 232 | 233 | @app.route("/histogram//.png") 234 | async def histogram(node, target): 235 | targets = [] 236 | for arg_target in [target] + request.args.getlist("compare"): 237 | try: 238 | targets.append(mp.get_target(arg_target)) 239 | except LookupError: 240 | print("lookuperror") 241 | abort(404, description=f"Target {arg_target} not found") 242 | 243 | if len(targets) > 3: 244 | # an RGB image only has three channels 245 | abort(400, description="Can only compare up to three targets") 246 | 247 | try: 248 | img = histodraw.render(targets, mp.histogram_period) 249 | except ValueError as err: 250 | abort(404, description=err) 251 | 252 | img_io = BytesIO() 253 | img.save(img_io, 'png') 254 | length = img_io.tell() 255 | img_io.seek(0) 256 | 257 | resp = await send_file(img_io, mimetype='image/png') 258 | resp.headers["content-length"] = length 259 | resp.headers["refresh"] = "300" 260 | resp.headers["content-disposition"] = ( 261 | 'inline; filename="meshping_%s_%s.png"' % ( 262 | datetime.now().strftime("%Y-%m-%d_%H-%M-%S"), 263 | target 264 | ) 265 | ) 266 | 267 | return resp 268 | 269 | @app.route("/network.svg") 270 | async def network_diagram(): 271 | targets = mp.all_targets() 272 | uniq_hops = {} 273 | uniq_links = set() 274 | 275 | for target in targets: 276 | prev_hop = "SELF" 277 | prev_dist = 0 278 | for hop in target.traceroute: 279 | hop_id = hop["address"].replace(":", "_").replace(".", "_") 280 | 281 | # Check if we know this hop already. If we do, just skip ahead. 282 | if hop_id not in uniq_hops: 283 | # Fill in the blanks for missing hops, if any 284 | while hop["distance"] > prev_dist + 1: 285 | dummy_id = str(randint(10000000, 99999999)) 286 | dummy = dict(id=dummy_id, distance=(prev_dist + 1), address=None, name=None, target=None, whois=None) 287 | uniq_hops[dummy_id] = dummy 288 | uniq_links.add( (prev_hop, dummy_id) ) 289 | prev_hop = dummy_id 290 | prev_dist += 1 291 | 292 | # Now render the hop itself 293 | hop_id = hop["address"].replace(":", "_").replace(".", "_") 294 | uniq_hops.setdefault(hop_id, dict(hop, id=hop_id, target=None)) 295 | uniq_links.add( (prev_hop, hop_id) ) 296 | 297 | # make sure we show the most recent state info 298 | if ( 299 | uniq_hops[hop_id]["state"] != hop["state"] and 300 | uniq_hops[hop_id]["time"] < hop["time"] 301 | ): 302 | uniq_hops[hop_id].update(state=hop["state"], time=hop["time"]) 303 | 304 | if hop["address"] == target.addr: 305 | uniq_hops[hop_id]["target"] = target 306 | 307 | prev_hop = hop_id 308 | prev_dist = hop["distance"] 309 | 310 | 311 | now = datetime.now() 312 | 313 | tpl = await render_template( 314 | "network.puml", 315 | hostname = socket.gethostname(), 316 | now = now.strftime("%Y-%m-%d %H:%M:%S"), 317 | targets = targets, 318 | uniq_hops = uniq_hops, 319 | uniq_links = sorted(uniq_links), 320 | uniq_hops_sorted = [uniq_hops[hop] for hop in sorted(uniq_hops.keys())], 321 | ); 322 | 323 | plantuml = run_command(["plantuml", "-tsvg", "-p"], input=tpl.encode("utf-8"), capture_output=True) 324 | 325 | if plantuml.stderr: 326 | return Response(plantuml.stderr.decode("utf-8") + "\n\n===\n\n" + tpl, mimetype="text/plain"), 500 327 | 328 | resp = Response( 329 | plantuml.stdout, 330 | mimetype="image/svg+xml" 331 | ) 332 | 333 | resp.headers["refresh"] = "43200" # 12h 334 | resp.headers["Cache-Control"] = "max-age=36000, public" # 10h 335 | 336 | resp.headers["content-disposition"] = ( 337 | 'inline; filename="meshping_%s_network.svg"' % ( 338 | now.strftime("%Y-%m-%d_%H-%M-%S") 339 | ) 340 | ) 341 | 342 | return resp 343 | -------------------------------------------------------------------------------- /src/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import sqlite3 4 | import sys 5 | 6 | from dataclasses import dataclass 7 | from itertools import zip_longest 8 | from sqlite3 import OperationalError 9 | from packaging.version import parse as parse_version 10 | 11 | import pandas 12 | 13 | # WAL mode is supported since 3.7.0: https://sqlite.org/wal.html 14 | assert parse_version(sqlite3.sqlite_version) >= parse_version("3.7.0"), "need WAL mode support" 15 | 16 | # Upsert is supported since 3.24.0: https://www.sqlite.org/draft/lang_UPSERT.html 17 | assert parse_version(sqlite3.sqlite_version) >= parse_version("3.24.0"), "need UPSERT support" 18 | 19 | QRY_CREATE_TABLE_TARGETS = """ 20 | CREATE TABLE IF NOT EXISTS targets ( 21 | id INTEGER PRIMARY KEY, 22 | addr TEXT UNIQUE, 23 | name TEXT, 24 | UNIQUE (addr, name) 25 | ) 26 | """ 27 | 28 | QRY_CREATE_TABLE_HISTOGRAMS = """ 29 | CREATE TABLE IF NOT EXISTS histograms ( 30 | target_id INTEGER, 31 | timestamp INTEGER, 32 | bucket INTEGER, 33 | count INTEGER DEFAULT 1, 34 | FOREIGN KEY (target_id) REFERENCES targets(id), 35 | UNIQUE (target_id, timestamp, bucket) 36 | ) 37 | """ 38 | 39 | QRY_CREATE_TABLE_STATISTICS = """ 40 | CREATE TABLE IF NOT EXISTS statistics ( 41 | target_id INTEGER, 42 | field TEXT, 43 | value DOUBLE, 44 | FOREIGN KEY (target_id) REFERENCES targets(id), 45 | UNIQUE (target_id, field) 46 | ) 47 | """ 48 | 49 | QRY_CREATE_TABLE_META = """ 50 | CREATE TABLE IF NOT EXISTS meta ( 51 | target_id INTEGER, 52 | field TEXT, 53 | value TEXT, 54 | FOREIGN KEY (target_id) REFERENCES targets(id), 55 | UNIQUE (target_id, field) 56 | ) 57 | """ 58 | 59 | QRY_INSERT_TARGET = """ 60 | INSERT INTO targets (addr, name) VALUES (?, ?) 61 | ON CONFLICT (addr) DO NOTHING; 62 | """ 63 | 64 | QRY_RENAME_TARGET = """ 65 | INSERT INTO targets (addr, name) VALUES (?, ?) 66 | ON CONFLICT (addr) DO UPDATE 67 | SET name = excluded.name; 68 | """ 69 | 70 | QRY_INSERT_MEASUREMENT = """ 71 | INSERT INTO histograms (target_id, timestamp, bucket) VALUES (?, ?, ?) 72 | ON CONFLICT (target_id, timestamp, bucket) DO UPDATE 73 | SET count = count + 1; 74 | """ 75 | 76 | QRY_SELECT_MEASUREMENTS = """ 77 | SELECT h.timestamp, h.bucket, h.count 78 | FROM histograms h 79 | INNER JOIN targets t ON t.id = h.target_id 80 | WHERE t.addr = ? 81 | ORDER BY h.timestamp, h.bucket 82 | """ 83 | 84 | QRY_INSERT_STATS = """ 85 | INSERT INTO statistics (target_id, field, value) VALUES(?, ?, ?) 86 | ON CONFLICT (target_id, field) DO UPDATE 87 | SET value = excluded.value; 88 | """ 89 | 90 | QRY_INSERT_META = """ 91 | INSERT INTO meta (target_id, field, value) VALUES(?, ?, ?) 92 | ON CONFLICT (target_id, field) DO UPDATE 93 | SET value = excluded.value; 94 | """ 95 | 96 | QRY_SELECT_STATS = """ 97 | SELECT s.field, s.value 98 | FROM statistics s 99 | INNER JOIN targets t ON t.id = s.target_id 100 | WHERE t.addr = ? 101 | """ 102 | 103 | QRY_SELECT_META = """ 104 | SELECT m.field, m.value 105 | FROM meta m 106 | INNER JOIN targets t ON t.id = m.target_id 107 | WHERE t.addr = ? 108 | """ 109 | 110 | 111 | class Database: 112 | def __init__(self, path): 113 | self.path = path 114 | self.conn = sqlite3.connect(path) 115 | self.conn.execute('PRAGMA journal_mode = WAL;') 116 | self.conn.execute('PRAGMA foreign_keys = ON;') 117 | 118 | with self.conn: 119 | self.conn.execute(QRY_CREATE_TABLE_TARGETS) 120 | self.conn.execute(QRY_CREATE_TABLE_HISTOGRAMS) 121 | self.conn.execute(QRY_CREATE_TABLE_STATISTICS) 122 | self.conn.execute(QRY_CREATE_TABLE_META) 123 | 124 | def exec_read(self, query, args): 125 | return self.conn.execute(query, args) 126 | 127 | def exec_write(self, query, args): 128 | with self.conn: 129 | return self.conn.execute(query, args) 130 | 131 | def exec_write_many(self, query, list_of_args): 132 | with self.conn: 133 | self.conn.executemany(query, list_of_args) 134 | 135 | def add(self, addr, name): 136 | self.exec_write(QRY_INSERT_TARGET, (addr, name)) 137 | 138 | def get(self, addr): 139 | for row in self.conn.execute("SELECT id, addr, name FROM targets WHERE addr = ?", (addr, )): 140 | return Target(*row) 141 | raise LookupError(f"Target does not exist: {addr}") 142 | 143 | def all(self): 144 | for row in self.conn.execute('SELECT id, addr, name FROM targets'): 145 | yield Target(*row) 146 | 147 | def delete(self, target_id): 148 | with self.conn: 149 | self.conn.execute("DELETE FROM histograms WHERE target_id = ?", (target_id, )) 150 | self.conn.execute("DELETE FROM statistics WHERE target_id = ?", (target_id, )) 151 | self.conn.execute("DELETE FROM meta WHERE target_id = ?", (target_id, )) 152 | self.conn.execute("DELETE FROM targets WHERE id = ?", (target_id, )) 153 | 154 | def prune_histograms(self, before_timestamp): 155 | with self.conn: 156 | self.conn.execute( 157 | "DELETE FROM histograms WHERE timestamp < ?", 158 | (before_timestamp, ) 159 | ) 160 | 161 | def clear_statistics(self): 162 | with self.conn: 163 | # sqlite doesn't have truncate 164 | self.conn.execute("DELETE FROM statistics") 165 | self.conn.execute("DELETE FROM meta WHERE field = 'state'") 166 | 167 | 168 | def open_database(): 169 | db_path = os.path.join(os.environ.get("MESHPING_DATABASE_PATH", "db"), "meshping.db") 170 | try: 171 | return Database(db_path) 172 | except OperationalError as err: 173 | print(f"Could not open database {db_path}: {err}", file=sys.stderr) 174 | sys.exit(1) 175 | 176 | 177 | @dataclass(frozen=True) 178 | class Target: 179 | id: int 180 | addr: str 181 | name: str 182 | 183 | db = open_database() 184 | 185 | def rename(self, name): 186 | self.db.exec_write(QRY_RENAME_TARGET, (self.addr, name)) 187 | 188 | def delete(self): 189 | self.db.delete(self.id) 190 | 191 | @property 192 | def histogram(self): 193 | return pandas.read_sql_query( 194 | sql = QRY_SELECT_MEASUREMENTS, 195 | con = self.db.conn, 196 | params = (self.addr, ), 197 | parse_dates = {'timestamp': 's'} 198 | ).pivot( # flip the dataframe: turn each value of the 199 | index="timestamp", # "bucket" DB column into a separate column in 200 | columns="bucket", # the DF, using the timestamp as the index 201 | values="count" # and the count for the values. 202 | ).fillna(0) # replace NaNs with zero 203 | 204 | def add_measurement(self, timestamp, bucket): 205 | self.db.exec_write(QRY_INSERT_MEASUREMENT, (self.id, timestamp, bucket)) 206 | 207 | @property 208 | def statistics(self): 209 | stats = { 210 | "sent": 0, "lost": 0, "recv": 0, "sum": 0 211 | } 212 | stats.update(self.db.exec_read(QRY_SELECT_STATS, (self.addr, ))) 213 | return stats 214 | 215 | @property 216 | def meta(self): 217 | return dict(self.db.exec_read(QRY_SELECT_META, (self.addr, ))) 218 | 219 | def update_statistics(self, stats): 220 | self.db.exec_write_many(QRY_INSERT_STATS, [ 221 | (self.id, field, value) 222 | for field, value in stats.items() 223 | ]) 224 | 225 | def update_meta(self, meta): 226 | self.db.exec_write_many(QRY_INSERT_META, [ 227 | (self.id, field, value) 228 | for field, value in meta.items() 229 | ]) 230 | 231 | @property 232 | def is_foreign(self): 233 | return self.meta.get("is_foreign", "false") == "true" 234 | 235 | def set_is_foreign(self, is_foreign): 236 | self.update_meta({"is_foreign": str(is_foreign).lower()}) 237 | 238 | @property 239 | def state(self): 240 | return self.meta.get("state", "unknown") 241 | 242 | def set_state(self, state): 243 | if state not in ("up", "down", "unknown"): 244 | raise ValueError(f'state must be one of ("up", "down", "unknown"), found {state}') 245 | self.update_meta({"state": str(state)}) 246 | 247 | @property 248 | def traceroute(self): 249 | curr = json.loads(self.meta.get("traceroute", "[]")) 250 | lkgt = json.loads(self.meta.get("lkgt", "[]")) # last known good traceroute 251 | if not curr or not lkgt or len(lkgt) < len(curr): 252 | # we probably don't know all the nodes, but the ones we do know are up 253 | return [dict(hop, state="up") for hop in curr] 254 | elif curr[-1]["address"] == self.addr: 255 | # Trace has reached the target itself, thus all hops are up 256 | return [dict(hop, state="up") for hop in curr] 257 | else: 258 | # Check with lkgt to see which hops are still there 259 | result = [] 260 | for (lkgt_hop, curr_hop) in zip_longest(lkgt, curr): 261 | if lkgt_hop is None: 262 | # This should not be able to happen, because we checked 263 | # len(lkgt) < len(curr) above. 264 | raise ValueError("lost known good traceroute: hop is None") 265 | if curr_hop is None: 266 | # hops missing from current traceroute are down 267 | result.append( dict(lkgt_hop, state="down") ) 268 | elif curr_hop.get("address") != lkgt_hop.get("address"): 269 | result.append( dict(curr_hop, state="different") ) 270 | else: 271 | result.append( dict(curr_hop, state="up") ) 272 | return result 273 | 274 | def set_traceroute(self, hops): 275 | traceroutes = { 276 | "traceroute": json.dumps(hops) 277 | } 278 | if hops and hops[-1]["address"] == self.addr: 279 | # Store last known good traceroute 280 | traceroutes["lkgt"] = traceroutes["traceroute"] 281 | self.update_meta(traceroutes) 282 | 283 | @property 284 | def route_loop(self): 285 | return self.meta.get("route_loop", "false") == "true" 286 | 287 | def set_route_loop(self, route_loop): 288 | self.update_meta({ 289 | "route_loop": ("true" if route_loop else "false") 290 | }) 291 | 292 | @property 293 | def error(self): 294 | return self.meta.get("error") 295 | 296 | def set_error(self, error): 297 | self.update_meta({"state": "error", "error": error}) 298 | 299 | @property 300 | def label(self): 301 | if self.name == self.addr: 302 | return self.name 303 | return f"{self.name} ({self.addr})" 304 | -------------------------------------------------------------------------------- /src/histodraw.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # kate: space-indent on; indent-width 4; replace-tabs on; 4 | 5 | import socket 6 | import os 7 | import pytz 8 | import numpy as np 9 | import pandas 10 | 11 | from datetime import datetime, timedelta 12 | from PIL import Image, ImageDraw, ImageFont, ImageOps 13 | 14 | # How big do you want the squares to be? 15 | sqsz = 8 16 | 17 | def render_target(target): 18 | histograms_df = target.histogram 19 | if histograms_df.empty: 20 | return None 21 | 22 | # Normalize Buckets by transforming the number of actual pings sent 23 | # into a float [0..1] indicating the grayness of that bucket. 24 | biggestbkt = histograms_df.max().max() 25 | histograms_df = histograms_df.div(biggestbkt, axis="index") 26 | # prune outliers -> keep only values > 5% 27 | histograms_df = histograms_df[histograms_df > 0.05] 28 | # drop columns that contain only NaNs now 29 | histograms_df = histograms_df.dropna(axis="columns", how="all") 30 | # fill missing _rows_ (aka, hours) with rows of just NaN 31 | histograms_df = histograms_df.asfreq("1h") 32 | # replace all the NaNs with 0 33 | histograms_df = histograms_df.fillna(0) 34 | 35 | # detect dynamic range, and round to the nearest multiple of 10. 36 | # this ensures that the ticks are drawn at powers of 2, which makes 37 | # the graph more easily understandable. (I hope.) 38 | # Btw: 27 // 10 * 10 # = 20 39 | # -27 // 10 * 10 # = -30 40 | # hmax needs to be nearest power of 10 + 1 for the top tick to be drawn. 41 | hmin = histograms_df.columns.min() // 10 * 10 42 | hmax = histograms_df.columns.max() // 10 * 10 + 11 43 | 44 | # Draw the graph in a pixels array which we then copy to an image 45 | height = hmax - hmin + 1 46 | width = len(histograms_df) 47 | pixels = np.zeros(width * height) 48 | 49 | for col, (_tstamp, histogram) in enumerate(histograms_df.iterrows()): 50 | for bktval, bktgrayness in histogram.items(): 51 | # ( y ) (x) 52 | pixels[((hmax - bktval) * width) + col] = bktgrayness 53 | 54 | # copy pixels to an Image and paste that into the output image 55 | graph = Image.new("L", (width, height)) 56 | graph.putdata(pixels * 0xFF) 57 | 58 | # Scale graph so each Pixel becomes a square 59 | width *= sqsz 60 | height *= sqsz 61 | 62 | graph = graph.resize((width, height), Image.NEAREST) 63 | graph.hmin = hmin 64 | graph.hmax = hmax 65 | graph.tmin = histograms_df.index.min() 66 | graph.tmax = histograms_df.index.max() 67 | return graph 68 | 69 | 70 | def render(targets, histogram_period): 71 | rendered_graphs = [] 72 | 73 | for target in targets: 74 | target_graph = render_target(target) 75 | if target_graph is None: 76 | raise ValueError(f"No data available for target {target}") 77 | rendered_graphs.append(target_graph) 78 | 79 | width = histogram_period // 3600 * sqsz 80 | hmin = min(graph.hmin for graph in rendered_graphs) 81 | hmax = max(graph.hmax for graph in rendered_graphs) 82 | tmax = max(graph.tmax for graph in rendered_graphs) 83 | height = (hmax - hmin) * sqsz 84 | 85 | if len(rendered_graphs) == 1: 86 | # Single graph -> use it as-is 87 | graph = Image.new("L", (width, height), "white") 88 | graph.paste( 89 | ImageOps.invert(rendered_graphs[0]), 90 | (width - rendered_graphs[0].width, 0) 91 | ) 92 | else: 93 | # Multiple graphs -> merge. 94 | # This width/height may not match what we need for the output. 95 | # Check for which graphs that is the case, and for these, 96 | # create a new image that has the correct size and paste 97 | # the graph into it. 98 | resized_graphs = [] 99 | for graph in rendered_graphs: 100 | dtmax = (tmax - graph.tmax) // pandas.Timedelta(hours=1) 101 | if graph.width != width or graph.height != height: 102 | new_graph = Image.new("L", (width, height), "black") 103 | new_graph.paste(graph, 104 | (width - graph.width - sqsz * dtmax, 105 | (hmax - graph.hmax) * sqsz) 106 | ) 107 | else: 108 | new_graph = graph 109 | 110 | resized_graphs.append(new_graph) 111 | 112 | while len(resized_graphs) != 3: 113 | resized_graphs.append(Image.new("L", (width, height), "black")) 114 | 115 | # Print the graph, on black background still. 116 | graph = Image.merge("RGB", resized_graphs) 117 | 118 | # To get a white background, convert to HSV and set V=1. 119 | # V currently contains the interesting information though, 120 | # so move that to S first. 121 | hsv = np.array(graph.convert("HSV")) 122 | # Add V to S (not sure why adding works better than replacing, but it does) 123 | hsv[:, :, 1] = hsv[:, :, 1] + hsv[:, :, 2] 124 | # Set V to 1 125 | hsv[:, :, 2] = np.ones((height, width)) * 0xFF 126 | graph = Image.fromarray(hsv, "HSV").convert("RGB") 127 | 128 | # position of the graph 129 | graph_x = 70 130 | graph_y = 30 * len(targets) + 10 131 | 132 | # im will hold the output image 133 | im = Image.new("RGB", (graph_x + width + 20, graph_y + height + 100), "white") 134 | im.paste(graph, (graph_x, graph_y)) 135 | 136 | # draw a rect around the graph 137 | draw = ImageDraw.Draw(im) 138 | draw.rectangle((graph_x, graph_y, graph_x + width - 1, graph_y + height - 1), outline=0x333333) 139 | 140 | try: 141 | font = ImageFont.truetype("DejaVuSansMono.ttf", 10) 142 | lgfont = ImageFont.truetype("DejaVuSansMono.ttf", 16) 143 | except IOError: 144 | font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 10) 145 | lgfont = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 16) 146 | 147 | # Headline 148 | if len(targets) == 1: # just black for a single graph 149 | targets_with_colors = zip(targets, (0x000000, )) 150 | else: # red, green, blue for multiple graphs 151 | targets_with_colors = zip(targets, (0x0000FF, 0x00FF00, 0xFF0000)) 152 | 153 | for idx, (target, color) in enumerate(targets_with_colors): 154 | headline_text = "%s → %s" % (socket.gethostname(), target.label) 155 | headline_width = lgfont.getlength(headline_text) 156 | draw.text( 157 | ( 158 | (graph_x + width + 20 - headline_width) // 2, 159 | 30 * idx + 11 160 | ), 161 | headline_text, color, font=lgfont 162 | ) 163 | 164 | # Y axis ticks and annotations 165 | for hidx in range(hmin, hmax, 5): 166 | bottomrow = hidx - hmin 167 | offset_y = height + graph_y - bottomrow * sqsz - 1 168 | draw.line((graph_x - 2, offset_y, graph_x + 2, offset_y), fill=0xAAAAAA) 169 | 170 | ping = 2 ** (hidx / 10.) 171 | label = f"{ping:.2f}" 172 | draw.text((graph_x - len(label) * 6 - 10, offset_y - 5), label, 0x333333, font=font) 173 | 174 | # Calculate the times at which the histogram begins and ends. 175 | t_hist_end = ( 176 | # Latest hour for which we have data... 177 | tmax.tz_localize("Etc/UTC") 178 | .tz_convert(os.environ.get("TZ", "Etc/UTC")) 179 | .to_pydatetime() 180 | # Plus the current hour which we're also drawing on screen 181 | + timedelta(hours=1) 182 | ) 183 | 184 | t_hist_begin = t_hist_end - timedelta(hours=(histogram_period // 3600)) 185 | 186 | # X axis ticks - one every two hours 187 | for col in range(1, width // sqsz): 188 | # We're now at hour indicated by col 189 | if (t_hist_begin + timedelta(hours=col)).hour % 2 != 0: 190 | continue 191 | offset_x = graph_x + col * sqsz 192 | draw.line((offset_x, height + graph_y - 2, offset_x, height + graph_y + 2), fill=0xAAAAAA) 193 | 194 | # X axis annotations 195 | # Create a temp image for the bottom label that we then rotate by 90° and attach to the other one 196 | # since this stuff is rotated by 90° while we create it, all the coordinates are inversed... 197 | tmpim = Image.new("RGB", (80, width + 20), "white") 198 | tmpdraw = ImageDraw.Draw(tmpim) 199 | 200 | # Draw one annotation every four hours 201 | for col in range(0, width // sqsz + 1): 202 | # We're now at hour indicated by col 203 | tstamp = t_hist_begin + timedelta(hours=col) 204 | if tstamp.hour % 4 != 0: 205 | continue 206 | offset_x = col * sqsz 207 | if tstamp.hour == 0: 208 | tmpdraw.text(( 0, offset_x + 4), tstamp.strftime("%m-%d"), 0x333333, font=font) 209 | tmpdraw.text( (36, offset_x + 4), tstamp.strftime("%H:%M"), 0x333333, font=font) 210 | 211 | im.paste( tmpim.rotate(90, expand=1), (graph_x - 10, height + graph_y + 1) ) 212 | 213 | # This worked pretty well for Tobi Oetiker... 214 | tmpim = Image.new("RGB", (170, 13), "white") 215 | tmpdraw = ImageDraw.Draw(tmpim) 216 | tmpdraw.text((0, 0), "Meshping by Michael Ziegler", 0x999999, font=font) 217 | im.paste( tmpim.rotate(270, expand=1), (width + graph_x + 7, graph_y) ) 218 | 219 | return im 220 | -------------------------------------------------------------------------------- /src/ifaces.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import ipaddress 3 | import logging 4 | import netifaces 5 | 6 | class Ifaces4: 7 | def __init__(self): 8 | self.addrs = [] 9 | self.networks = [] 10 | # Get addresses from our interfaces 11 | for iface in netifaces.interfaces(): 12 | try: 13 | ifaddrs = netifaces.ifaddresses(iface) 14 | except ValueError: 15 | logging.warning( 16 | "Could not retrieve addresses of interface %s, ignoring interface", 17 | iface, 18 | exc_info=True 19 | ) 20 | continue 21 | 22 | for family, addresses in ifaddrs.items(): 23 | if family != socket.AF_INET: 24 | continue 25 | 26 | for addrinfo in addresses: 27 | self.addrs.append(ipaddress.ip_address(addrinfo["addr"])) 28 | self.networks.append( 29 | ipaddress.IPv4Network("%s/%s" % ( 30 | ipaddress.ip_address(addrinfo["addr"]), 31 | ipaddress.ip_address(addrinfo["netmask"]) 32 | ), strict=False) 33 | ) 34 | 35 | def find_iface_for_network(self, target): 36 | target = ipaddress.IPv4Address(target) 37 | for addr in self.networks: 38 | if target in addr: 39 | return addr 40 | return None 41 | 42 | def is_local(self, target): 43 | return self.find_iface_for_network(target) is not None 44 | 45 | def is_interface(self, target): 46 | target = ipaddress.IPv4Address(target) 47 | return target in self.addrs 48 | 49 | class Ifaces6: 50 | def __init__(self): 51 | self.addrs = [] 52 | self.networks = [] 53 | # Get addresses from our interfaces 54 | for iface in netifaces.interfaces(): 55 | try: 56 | ifaddrs = netifaces.ifaddresses(iface) 57 | except ValueError: 58 | logging.warning( 59 | "Could not retrieve addresses of interface %s, ignoring interface", 60 | iface, 61 | exc_info=True 62 | ) 63 | continue 64 | 65 | for family, addresses in ifaddrs.items(): 66 | if family != socket.AF_INET6: 67 | continue 68 | 69 | for addrinfo in addresses: 70 | if "%" in addrinfo["addr"]: 71 | part_addr, part_iface = addrinfo["addr"].split("%", 1) 72 | assert part_iface == iface 73 | addrinfo["addr"] = part_addr 74 | 75 | # netmask is ffff:ffff:ffff:etc:ffff/128 for some reason, we only need the length 76 | addrinfo["netmask"] = int(addrinfo["netmask"].split("/")[1], 10) 77 | 78 | self.addrs.append(ipaddress.ip_address(addrinfo["addr"])) 79 | self.networks.append( 80 | ipaddress.IPv6Network("%s/%d" % ( 81 | ipaddress.ip_address(addrinfo["addr"]), 82 | addrinfo["netmask"] 83 | ), strict=False) 84 | ) 85 | 86 | def find_iface_for_network(self, target): 87 | target = ipaddress.IPv6Address(target) 88 | for addr in self.networks: 89 | if target in addr: 90 | return addr 91 | return None 92 | 93 | def is_local(self, target): 94 | return self.find_iface_for_network(target) is not None 95 | 96 | def is_interface(self, target): 97 | target = ipaddress.IPv4Address(target) 98 | return target in self.addrs 99 | 100 | 101 | def test(): 102 | if4 = Ifaces4() 103 | 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"): 104 | print("%s -> %s" % (target, if4.is_local(target))) 105 | 106 | if6 = Ifaces6() 107 | for target in ("2001:4860:4860::8888", "2001:4860:4860::8844", "::1"): 108 | print("%s -> %s" % (target, if6.is_local(target))) 109 | 110 | if __name__ == '__main__': 111 | test() 112 | -------------------------------------------------------------------------------- /src/meshping.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # kate: space-indent on; indent-width 4; replace-tabs on; 4 | 5 | import os 6 | import os.path 7 | import math 8 | import sys 9 | import logging 10 | 11 | from uuid import uuid4 12 | from time import time 13 | from markupsafe import Markup 14 | from quart_trio import QuartTrio 15 | from icmplib import traceroute 16 | from ipwhois import IPWhois, IPDefinedError 17 | from netaddr import IPAddress, IPNetwork 18 | 19 | import trio 20 | 21 | from oping import PingObj, PingError 22 | from api import add_api_views 23 | from peers import run_peers 24 | from db import Target 25 | from socklib import reverse_lookup, ip_pmtud 26 | 27 | INTERVAL = 30 28 | 29 | FAC_15m = math.exp(-INTERVAL / ( 15 * 60.)) 30 | FAC_6h = math.exp(-INTERVAL / ( 6 * 60 * 60.)) 31 | FAC_24h = math.exp(-INTERVAL / (24 * 60 * 60.)) 32 | 33 | def exp_avg(current_avg, add_value, factor): 34 | if current_avg is None: 35 | return add_value 36 | return (current_avg * factor) + (add_value * (1 - factor)) 37 | 38 | async def sleep_until(when): 39 | now = time() 40 | if now < when: 41 | await trio.sleep(when - now) 42 | 43 | class MeshPing: 44 | def __init__(self, timeout=5, interval=30, histogram_days=3, traceroute_interval=900): 45 | assert interval > timeout, "Interval must be larger than the timeout" 46 | self.timeout = timeout 47 | self.interval = interval 48 | self.histogram_period = histogram_days * 86400 49 | self.traceroute_interval = traceroute_interval 50 | 51 | self.whois_cache = {} 52 | 53 | def all_targets(self): 54 | return Target.db.all() 55 | 56 | def add_target(self, target): 57 | assert "@" in target 58 | name, addr = target.split("@", 1) 59 | Target.db.add(addr, name) 60 | 61 | def remove_target(self, addr): 62 | Target.db.get(addr).delete() 63 | 64 | def get_target(self, addr): 65 | return Target.db.get(addr) 66 | 67 | def clear_statistics(self): 68 | Target.db.clear_statistics() 69 | 70 | async def run_traceroutes(self): 71 | while True: 72 | now = time() 73 | next_run = now + self.traceroute_interval 74 | pmtud_cache = {} 75 | for target in Target.db.all(): 76 | trace = await trio.to_thread.run_sync( 77 | lambda tgtaddr: traceroute(tgtaddr, fast=True, timeout=0.5, count=1), 78 | target.addr 79 | ) 80 | 81 | hopaddrs = [hop.address for hop in trace] 82 | hoaddrs_set = set(hopaddrs) 83 | target.set_route_loop( 84 | len(hopaddrs) != len(hoaddrs_set) and len(hoaddrs_set) > 1 85 | ) 86 | 87 | trace_hops = [] 88 | for hop in trace: 89 | if hop.address not in pmtud_cache: 90 | pmtud_cache[hop.address] = ip_pmtud(hop.address) 91 | 92 | trace_hops.append({ 93 | "name": reverse_lookup(hop.address), 94 | "distance":hop.distance, 95 | "address": hop.address, 96 | "max_rtt": hop.max_rtt, 97 | "pmtud": pmtud_cache[hop.address], 98 | "whois": self.whois(hop.address), 99 | "time": now, 100 | }) 101 | 102 | target.set_traceroute(trace_hops) 103 | 104 | # Running a bunch'a traceroutes all at once might trigger our default 105 | # gw's rate limiting if it receives too many packets with a ttl of 1 106 | # too quickly. Let's go a bit slower so that it doesn't stop sending 107 | # "ttl exceeded" replies and messing up our results. 108 | await trio.sleep(2) 109 | 110 | await sleep_until(next_run) 111 | 112 | 113 | def whois(self, hop_address): 114 | # If we know this address already and it's up-to-date, skip it 115 | now = int(time()) 116 | if ( 117 | hop_address in self.whois_cache and 118 | self.whois_cache[hop_address].get("last_check", 0) + 72*3600 < now 119 | ): 120 | return self.whois_cache[hop_address] 121 | 122 | # Check if the IP is private or reserved 123 | addr = IPAddress(hop_address) 124 | if (addr.version == 4 and ( 125 | addr in IPNetwork("10.0.0.0/8") or 126 | addr in IPNetwork("172.16.0.0/12") or 127 | addr in IPNetwork("192.168.0.0/16") or 128 | addr in IPNetwork("100.64.0.0/10") 129 | )) or ( 130 | addr.version == 6 and 131 | addr not in IPNetwork("2000::/3") 132 | ): 133 | return {} 134 | 135 | # It's not, look up whois info 136 | try: 137 | self.whois_cache[hop_address] = dict( 138 | IPWhois(hop_address).lookup_rdap(), 139 | last_check=now 140 | ) 141 | except IPDefinedError: 142 | # RFC1918, RFC6598 or something else 143 | return {} 144 | except Exception as err: 145 | logging.warning("Could not query whois for IP %s: %s", hop_address, err) 146 | return self.whois_cache[hop_address] 147 | 148 | async def run(self): 149 | pingobj = PingObj() 150 | pingobj.set_timeout(self.timeout) 151 | 152 | next_ping = time() + 0.1 153 | 154 | current_targets = set() 155 | 156 | while True: 157 | now = time() 158 | next_ping = now + self.interval 159 | 160 | # Run DB housekeeping 161 | Target.db.prune_histograms(before_timestamp=(now - self.histogram_period)) 162 | 163 | unseen_targets = current_targets.copy() 164 | for target in Target.db.all(): 165 | if target.addr not in current_targets: 166 | current_targets.add(target.addr) 167 | try: 168 | pingobj.add_host(target.addr.encode("utf-8")) 169 | except PingError as err: 170 | target.set_error(err.args[0].decode("utf-8")) 171 | if target.addr in unseen_targets: 172 | unseen_targets.remove(target.addr) 173 | 174 | for target_addr in unseen_targets: 175 | current_targets.remove(target_addr) 176 | try: 177 | pingobj.remove_host(target_addr.encode("utf-8")) 178 | except PingError: 179 | # Host probably not there anyway 180 | pass 181 | 182 | # If we don't have any targets, we're done for now -- just sleep 183 | if not current_targets: 184 | await sleep_until(next_ping) 185 | continue 186 | 187 | # We do have targets, so first, let's ping them 188 | await trio.to_thread.run_sync( 189 | lambda: pingobj.send() 190 | ) 191 | 192 | for hostinfo in pingobj.get_hosts(): 193 | hostinfo["addr"] = hostinfo["addr"].decode("utf-8") 194 | 195 | try: 196 | self.process_ping_result(now, hostinfo) 197 | except LookupError: 198 | # ping takes a while. it's possible that while we were busy, this 199 | # target has been deleted from the DB. If so, forget about it. 200 | if hostinfo["addr"] in current_targets: 201 | current_targets.remove(hostinfo["addr"]) 202 | 203 | await sleep_until(next_ping) 204 | 205 | def process_ping_result(self, timestamp, hostinfo): 206 | target = self.get_target(hostinfo["addr"]) 207 | target_stats = target.statistics 208 | target_stats["sent"] += 1 209 | 210 | if hostinfo["latency"] != -1: 211 | target.set_state("up") 212 | target_stats["recv"] += 1 213 | target_stats["last"] = hostinfo["latency"] 214 | target_stats["sum"] += target_stats["last"] 215 | target_stats["max"] = max(target_stats.get("max", 0), target_stats["last"]) 216 | target_stats["min"] = min(target_stats.get("min", float('inf')), target_stats["last"]) 217 | target_stats["avg15m"] = exp_avg(target_stats.get("avg15m"), target_stats["last"], FAC_15m) 218 | target_stats["avg6h" ] = exp_avg(target_stats.get("avg6h"), target_stats["last"], FAC_6h ) 219 | target_stats["avg24h"] = exp_avg(target_stats.get("avg24h"), target_stats["last"], FAC_24h) 220 | 221 | target.add_measurement( 222 | timestamp = timestamp // 3600 * 3600, 223 | bucket = int(math.log(hostinfo["latency"], 2) * 10) 224 | ) 225 | 226 | else: 227 | target.set_state("down") 228 | target_stats["lost"] += 1 229 | 230 | target.update_statistics(target_stats) 231 | 232 | 233 | def build_app(): 234 | if os.getuid() != 0: 235 | raise RuntimeError("need to be root, sorry about that") 236 | 237 | known_env_vars = ( 238 | "MESHPING_DATABASE_PATH", 239 | "MESHPING_PING_TIMEOUT", 240 | "MESHPING_PING_INTERVAL", 241 | "MESHPING_TRACEROUTE_INTERVAL", 242 | "MESHPING_HISTOGRAM_DAYS", 243 | "MESHPING_PEERS", 244 | "MESHPING_PEERING_INTERVAL", 245 | "MESHPING_PROMETHEUS_URL", 246 | "MESHPING_PROMETHEUS_QUERY", 247 | "MESHPING_REDIS_HOST", 248 | "MESHPING_DEV", 249 | ) 250 | 251 | deprecated_env_vars = ( 252 | "MESHPING_PROMETHEUS_URL", 253 | "MESHPING_PROMETHEUS_QUERY", 254 | "MESHPING_REDIS_HOST", 255 | ) 256 | 257 | for key in os.environ: 258 | if key.startswith("MESHPING_") and key not in known_env_vars: 259 | print(f"env var {key} is unknown", file=sys.stderr) 260 | sys.exit(1) 261 | if key.startswith("MESHPING_") and key in deprecated_env_vars: 262 | print(f"env var {key} is deprecated, ignored", file=sys.stderr) 263 | 264 | app = QuartTrio(__name__, static_url_path="") 265 | 266 | if os.environ.get("MESHPING_DEV", "false") == "true": 267 | app.config["TEMPLATES_AUTO_RELOAD"] = True 268 | 269 | app.secret_key = str(uuid4()) 270 | app.jinja_options = dict( 271 | variable_start_string = '{[', 272 | variable_end_string = ']}' 273 | ) 274 | 275 | @app.context_processor 276 | def _inject_icons(): 277 | # I'm not happy about hardcoding this path here, but I'm also not sure what else to do 278 | icons_dir = "/opt/meshping/ui/node_modules/bootstrap-icons/icons/" 279 | return dict( 280 | icons={ 281 | filename: Markup(open(os.path.join(icons_dir, filename), "r").read()) 282 | for filename in os.listdir(icons_dir) 283 | } 284 | ) 285 | 286 | mp = MeshPing( 287 | int(os.environ.get("MESHPING_PING_TIMEOUT", 5)), 288 | int(os.environ.get("MESHPING_PING_INTERVAL", 30)), 289 | int(os.environ.get("MESHPING_HISTOGRAM_DAYS", 3)), 290 | int(os.environ.get("MESHPING_TRACEROUTE_INTERVAL", 900)) 291 | ) 292 | 293 | add_api_views(app, mp) 294 | 295 | @app.before_serving 296 | async def _(): 297 | app.nursery.start_soon(mp.run) 298 | app.nursery.start_soon(mp.run_traceroutes) 299 | app.nursery.start_soon(run_peers, mp) 300 | 301 | return app 302 | 303 | app = build_app() 304 | 305 | if __name__ == '__main__': 306 | app.run(host="::", port=9922, debug=False, use_reloader=False) 307 | -------------------------------------------------------------------------------- /src/peers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | import httpx 5 | import trio 6 | 7 | from ifaces import Ifaces4, Ifaces6 8 | 9 | async def run_peers(mp): 10 | peers = os.environ.get("MESHPING_PEERS", "") 11 | if peers: 12 | peers = peers.split(",") 13 | else: 14 | return 15 | 16 | while True: 17 | if4 = Ifaces4() 18 | if6 = Ifaces6() 19 | 20 | def is_local(addr): 21 | try: 22 | return if4.is_local(addr) 23 | except ValueError: 24 | pass 25 | try: 26 | return if6.is_local(addr) 27 | except ValueError: 28 | pass 29 | return False 30 | 31 | peer_targets = [ 32 | dict( 33 | name = target.name, 34 | addr = target.addr, 35 | local = is_local(target.addr) 36 | ) 37 | for target in mp.all_targets() 38 | if not target.is_foreign # ENOFORN 39 | ] 40 | 41 | async with httpx.AsyncClient() as client: 42 | for peer in peers: 43 | try: 44 | await client.post( 45 | f"http://{peer}/peer", 46 | headers={ 47 | "Content-Type": "application/json" 48 | }, 49 | data=json.dumps(dict(targets=peer_targets)) 50 | ) 51 | except Exception as err: 52 | logging.warning("Could not connect to peer %s: %s", peer, err) 53 | 54 | await trio.sleep( 55 | int(os.environ.get("MESHPING_PEERING_INTERVAL", 30)) 56 | ) 57 | -------------------------------------------------------------------------------- /src/socklib.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from icmplib.sockets import ICMPv4Socket, ICMPv6Socket 4 | from icmplib.exceptions import ICMPSocketError, TimeoutExceeded 5 | from icmplib.models import ICMPRequest 6 | from icmplib.utils import unique_identifier 7 | 8 | # see /usr/include/linux/in.h 9 | IP_MTU_DISCOVER = 10 10 | IP_PMTUDISC_DO = 2 11 | IP_MTU = 14 12 | IP_HEADER_LEN = 20 13 | ICMP_HEADER_LEN = 8 14 | 15 | # see /usr/include/linux/in6.h 16 | IPV6_MTU_DISCOVER = 23 17 | IPV6_PMTUDISC_DO = 2 18 | IPV6_MTU = 24 19 | IPV6_HEADER_LEN = 40 20 | ICMPV6_HEADER_LEN = 8 21 | 22 | def reverse_lookup(ip): 23 | try: 24 | return socket.gethostbyaddr(ip)[0] 25 | except socket.herror: 26 | return ip 27 | 28 | 29 | class PMTUDv4Socket(ICMPv4Socket): 30 | def _create_socket(self, type): 31 | sock = super()._create_socket(type) 32 | sock.setsockopt(socket.IPPROTO_IP, IP_MTU_DISCOVER, IP_PMTUDISC_DO) 33 | return sock 34 | 35 | def get_header_len(self): 36 | return IP_HEADER_LEN + ICMP_HEADER_LEN 37 | 38 | def get_mtu(self): 39 | return self._sock.getsockopt(socket.IPPROTO_IP, IP_MTU) 40 | 41 | def send(self, request): 42 | self._sock.connect((request.destination, 0)) 43 | return super(PMTUDv4Socket, self).send(request) 44 | 45 | 46 | class PMTUDv6Socket(ICMPv6Socket): 47 | def _create_socket(self, type): 48 | sock = super()._create_socket(type) 49 | sock.setsockopt(socket.IPPROTO_IPV6, IPV6_MTU_DISCOVER, IPV6_PMTUDISC_DO) 50 | sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_DONTFRAG, 1) 51 | return sock 52 | 53 | def get_header_len(self): 54 | return IPV6_HEADER_LEN + ICMPV6_HEADER_LEN 55 | 56 | def get_mtu(self): 57 | return self._sock.getsockopt(socket.IPPROTO_IPV6, IPV6_MTU) 58 | 59 | def send(self, request): 60 | self._sock.connect((request.destination, 0)) 61 | return super(PMTUDv6Socket, self).send(request) 62 | 63 | 64 | def ip_pmtud(ip): 65 | mtu = 9999 66 | 67 | try: 68 | addrinfo = socket.getaddrinfo(ip, 0, type=socket.SOCK_DGRAM)[0] 69 | except socket.gaierror as err: 70 | return {"state": "error", "error": str(err), "mtu": mtu} 71 | 72 | if addrinfo[0] == socket.AF_INET6: 73 | sock = PMTUDv6Socket(address=None, privileged=True) 74 | else: 75 | sock = PMTUDv4Socket(address=None, privileged=True) 76 | 77 | with sock: 78 | ping_id = unique_identifier() 79 | for sequence in range(30): 80 | request = ICMPRequest( 81 | destination = ip, 82 | id = ping_id, 83 | sequence = sequence, 84 | payload_size = mtu - sock.get_header_len() 85 | ) 86 | try: 87 | # deliberately send a way-too-large packet to provoke an error. 88 | # if the ping is successful, we found the MTU. 89 | sock.send(request) 90 | sock.receive(request, 1) 91 | return {"state": "up", "mtu": mtu} 92 | 93 | except TimeoutExceeded: 94 | # Target down, but no error -> MTU is probably fine. 95 | return {"state": "down", "mtu": mtu} 96 | 97 | except (ICMPSocketError, OSError) as err: 98 | if "Errno 90" not in str(err): 99 | return {"state": "error", "error": str(err), "mtu": mtu} 100 | 101 | new_mtu = sock.get_mtu() 102 | if new_mtu == mtu: 103 | break 104 | mtu = new_mtu 105 | 106 | return {"state": "ttl_exceeded", "mtu": mtu} 107 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {[ Hostname ]} — Meshping 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 |
18 |

Meshping: {[ Hostname ]}

19 |
20 | 38 |
39 | × 40 | 41 |
42 |
43 | × 44 | 45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 76 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 124 | 125 | 126 | 127 | 131 | 135 | 139 | 140 | 141 | 150 | 151 |
 TargetAddressSentRecvSuccLossMinAvg15mAvg6hAvg24hMaxLast 
Loading
No targets configured
No targets match your search
77 | 78 | {[ icons['check-circle.svg'] ]} 79 | 80 | 81 | {[ icons['arrow-up-right-circle.svg'] ]} 82 | 83 | 84 | {[ icons['x-circle.svg'] ]} 85 | 86 | 87 | {[ icons['arrow-clockwise.svg'] ]} 88 | 89 | 90 | {[ icons['question-circle.svg'] ]} 91 | 92 | 93 | {[ icons['exclamation-circle.svg'] ]} 94 | 95 | {{ target.name }}
{{ target.addr }}
{{ target.addr }}{{ target.sent }}{{ target.recv }}{{ target.succ | prettyFloat }}{{ target.loss | prettyFloat }}{{ target.min | prettyFloat }}{{ target.avg15m | prettyFloat }}{{ target.avg6h | prettyFloat }}{{ target.avg24h | prettyFloat }}{{ target.max | prettyFloat }}{{ target.last | prettyFloat }} 109 | 110 | 112 | graph 114 | 115 | del 118 | 119 | 120 | 122 | 123 |
  128 | 130 | 132 | 134 | 136 | 137 | 138 |    142 | 143 | 148 | 149 |
152 | 153 | 207 | 208 |
209 |
210 | 211 |
212 | github.com/Svedrin/meshping 213 | Meshping by Michael Ziegler 214 |
215 | 216 | 217 | -------------------------------------------------------------------------------- /src/templates/network.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | 20 | 21 | hide <> stereotype 22 | hide <> stereotype 23 | hide <> stereotype 24 | 25 | 26 | title Network Map 27 | 28 | node "{[ hostname ]}" <> as SELF 29 | 30 | {% for hop in uniq_hops_sorted -%} 31 | {% if hop.address -%} 32 | node "{% if hop.target %}{[ hop.target.name ]}{% else %}{[ hop.name ]}{% endif %}\n{[ hop.address ]}{% if hop.whois %}\n{[ hop.whois.network.name ]}{% endif %}{% if hop.target %}\n[[{[ url_for('histogram', node=hostname, target=hop.target.addr, _external=True)]} Histogram]]{% endif %}" <> as {[ hop.id ]} 33 | {% else -%} 34 | rectangle "?" as {[ hop.id ]} 35 | {% endif -%} 36 | {% endfor -%} 37 | 38 | {% for (lft, rgt) in uniq_links -%} 39 | "{[ lft ]}" -- "{[ rgt ]}" 40 | {% endfor -%} 41 | 42 | footer rendered on {[ now ]} by [[http://github.com/Svedrin/meshping Meshping]] using PlantUML 43 | @enduml 44 | -------------------------------------------------------------------------------- /ui/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meshping-ui", 3 | "version": "0.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@sindresorhus/is": { 8 | "version": "4.6.0", 9 | "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", 10 | "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" 11 | }, 12 | "@szmarczak/http-timer": { 13 | "version": "4.0.6", 14 | "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", 15 | "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", 16 | "requires": { 17 | "defer-to-connect": "^2.0.0" 18 | } 19 | }, 20 | "@types/cacheable-request": { 21 | "version": "6.0.3", 22 | "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", 23 | "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", 24 | "requires": { 25 | "@types/http-cache-semantics": "*", 26 | "@types/keyv": "^3.1.4", 27 | "@types/node": "*", 28 | "@types/responselike": "^1.0.0" 29 | } 30 | }, 31 | "@types/http-cache-semantics": { 32 | "version": "4.0.1", 33 | "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", 34 | "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" 35 | }, 36 | "@types/keyv": { 37 | "version": "3.1.4", 38 | "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", 39 | "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", 40 | "requires": { 41 | "@types/node": "*" 42 | } 43 | }, 44 | "@types/node": { 45 | "version": "18.11.18", 46 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", 47 | "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" 48 | }, 49 | "@types/responselike": { 50 | "version": "1.0.0", 51 | "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", 52 | "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", 53 | "requires": { 54 | "@types/node": "*" 55 | } 56 | }, 57 | "bootstrap": { 58 | "version": "4.4.1", 59 | "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.4.1.tgz", 60 | "integrity": "sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA==" 61 | }, 62 | "bootstrap-icons": { 63 | "version": "1.0.0", 64 | "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.0.0.tgz", 65 | "integrity": "sha512-PaQm3VtSqbUnWuyqGmFJG5iF9UMieDuk8raPOmKOtKeyWyiVshgLoKa+9EWGolGU/nvyBLEBWhZoQqhu9ccNBg==" 66 | }, 67 | "cacheable-lookup": { 68 | "version": "5.0.4", 69 | "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", 70 | "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==" 71 | }, 72 | "cacheable-request": { 73 | "version": "7.0.2", 74 | "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", 75 | "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", 76 | "requires": { 77 | "clone-response": "^1.0.2", 78 | "get-stream": "^5.1.0", 79 | "http-cache-semantics": "^4.0.0", 80 | "keyv": "^4.0.0", 81 | "lowercase-keys": "^2.0.0", 82 | "normalize-url": "^6.0.1", 83 | "responselike": "^2.0.0" 84 | } 85 | }, 86 | "clone-response": { 87 | "version": "1.0.3", 88 | "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", 89 | "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", 90 | "requires": { 91 | "mimic-response": "^1.0.0" 92 | } 93 | }, 94 | "decompress-response": { 95 | "version": "6.0.0", 96 | "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", 97 | "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", 98 | "requires": { 99 | "mimic-response": "^3.1.0" 100 | }, 101 | "dependencies": { 102 | "mimic-response": { 103 | "version": "3.1.0", 104 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 105 | "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" 106 | } 107 | } 108 | }, 109 | "defer-to-connect": { 110 | "version": "2.0.1", 111 | "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", 112 | "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==" 113 | }, 114 | "end-of-stream": { 115 | "version": "1.4.4", 116 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 117 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 118 | "requires": { 119 | "once": "^1.4.0" 120 | } 121 | }, 122 | "get-stream": { 123 | "version": "5.2.0", 124 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", 125 | "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", 126 | "requires": { 127 | "pump": "^3.0.0" 128 | } 129 | }, 130 | "got": { 131 | "version": "11.8.6", 132 | "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", 133 | "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", 134 | "requires": { 135 | "@sindresorhus/is": "^4.0.0", 136 | "@szmarczak/http-timer": "^4.0.5", 137 | "@types/cacheable-request": "^6.0.1", 138 | "@types/responselike": "^1.0.0", 139 | "cacheable-lookup": "^5.0.3", 140 | "cacheable-request": "^7.0.2", 141 | "decompress-response": "^6.0.0", 142 | "http2-wrapper": "^1.0.0-beta.5.2", 143 | "lowercase-keys": "^2.0.0", 144 | "p-cancelable": "^2.0.0", 145 | "responselike": "^2.0.0" 146 | } 147 | }, 148 | "http-cache-semantics": { 149 | "version": "4.1.1", 150 | "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", 151 | "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" 152 | }, 153 | "http2-wrapper": { 154 | "version": "1.0.3", 155 | "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", 156 | "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", 157 | "requires": { 158 | "quick-lru": "^5.1.1", 159 | "resolve-alpn": "^1.0.0" 160 | } 161 | }, 162 | "jquery": { 163 | "version": "3.5.0", 164 | "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.0.tgz", 165 | "integrity": "sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ==" 166 | }, 167 | "json-buffer": { 168 | "version": "3.0.1", 169 | "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", 170 | "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" 171 | }, 172 | "keyv": { 173 | "version": "4.5.2", 174 | "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz", 175 | "integrity": "sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==", 176 | "requires": { 177 | "json-buffer": "3.0.1" 178 | } 179 | }, 180 | "lowercase-keys": { 181 | "version": "2.0.0", 182 | "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", 183 | "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" 184 | }, 185 | "mimic-response": { 186 | "version": "1.0.1", 187 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", 188 | "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" 189 | }, 190 | "normalize-url": { 191 | "version": "6.1.0", 192 | "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", 193 | "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" 194 | }, 195 | "once": { 196 | "version": "1.4.0", 197 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 198 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 199 | "requires": { 200 | "wrappy": "1" 201 | } 202 | }, 203 | "p-cancelable": { 204 | "version": "2.1.1", 205 | "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", 206 | "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" 207 | }, 208 | "pump": { 209 | "version": "3.0.0", 210 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", 211 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", 212 | "requires": { 213 | "end-of-stream": "^1.1.0", 214 | "once": "^1.3.1" 215 | } 216 | }, 217 | "quick-lru": { 218 | "version": "5.1.1", 219 | "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", 220 | "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" 221 | }, 222 | "resolve-alpn": { 223 | "version": "1.2.1", 224 | "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", 225 | "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" 226 | }, 227 | "responselike": { 228 | "version": "2.0.1", 229 | "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", 230 | "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", 231 | "requires": { 232 | "lowercase-keys": "^2.0.0" 233 | } 234 | }, 235 | "vue": { 236 | "version": "2.6.11", 237 | "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz", 238 | "integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==" 239 | }, 240 | "vue-resource": { 241 | "version": "1.5.3", 242 | "resolved": "https://registry.npmjs.org/vue-resource/-/vue-resource-1.5.3.tgz", 243 | "integrity": "sha512-REhTuEuYSpwmEH/VN4fgDQVC/VXxDK/xsguuiDPnINxOwy1s0CSu//p++osTUkiAXi6d/vptwBpb0AcBIDsXzw==", 244 | "requires": { 245 | "got": ">=8.0 <12.0" 246 | } 247 | }, 248 | "wrappy": { 249 | "version": "1.0.2", 250 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 251 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meshping-ui", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "bootstrap": "^4.4.1", 13 | "bootstrap-icons": "^1.11.3", 14 | "jquery": "^3.5.0", 15 | "vue": "^2.6.11", 16 | "vue-resource": "^1.5.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/main.js: -------------------------------------------------------------------------------- 1 | window.app = new Vue({ 2 | el: '#app', 3 | data: { 4 | hostname: window.meshping_hostname, 5 | error_msg: "", 6 | success_msg: "", 7 | last_update: 0, 8 | search: localStorage.getItem("meshping_search") || "", 9 | targets_all: [], 10 | targets_filtered: [], 11 | add_tgt_name: "", 12 | add_tgt_addr: "", 13 | comparing: false, 14 | creating: false, 15 | route_target: {name: "", "traceroute": []}, 16 | }, 17 | methods: { 18 | update_targets: async function () { 19 | var response = await this.$http.get('/api/targets'); 20 | var json = await response.json(); 21 | this.targets_all = json.targets; 22 | this.last_update = new Date(); 23 | }, 24 | reapply_filters: function() { 25 | if( this.search === "" ){ 26 | // Make a copy of the array, or else chrome goes 100% CPU in sort() :o 27 | this.targets_filtered = this.targets_all.slice(); 28 | } else { 29 | var search = this.search.toLowerCase(); 30 | this.targets_filtered = this.targets_all.filter(function(target){ 31 | return ( 32 | target.name.toLowerCase().includes(search) || 33 | target.addr.includes(search) 34 | ); 35 | }); 36 | } 37 | var ip_as_filled_str = function(ipaddr) { 38 | if (!ipaddr.includes(":")) { 39 | // IPv4 40 | return (ipaddr 41 | .split(".") 42 | .map(x => x.toString().padStart(3, "0")) 43 | .join("") 44 | ); 45 | } else { 46 | // IPv6 47 | return (ipaddr 48 | .split(":") 49 | .map(x => x.toString().padStart(4, "0")) 50 | .join("") 51 | ); 52 | } 53 | } 54 | this.targets_filtered.sort(function(a, b){ 55 | return ip_as_filled_str(a.addr).localeCompare(ip_as_filled_str(b.addr)); 56 | }); 57 | }, 58 | delete_target: async function(target) { 59 | var message = `Delete target ${target.name} (${target.addr})?`; 60 | if (confirm(message)) { 61 | var response = await this.$http.delete(`/api/targets/${target.addr}`); 62 | var json = await response.json(); 63 | if (json.success) { 64 | this.show_success(`Success! Deleted target ${target.name} (${target.addr}).`); 65 | this.update_targets(); 66 | } 67 | } 68 | }, 69 | create_target: async function() { 70 | this.creating = true; 71 | var target_str = this.add_tgt_name; 72 | if (this.add_tgt_addr !== "") { 73 | target_str += "@" + this.add_tgt_addr; 74 | } 75 | var response = await this.$http.post('/api/targets', { 76 | "target": target_str 77 | }); 78 | var json = await response.json(); 79 | this.creating = false; 80 | if (json.success) { 81 | this.add_tgt_name = ""; 82 | this.add_tgt_addr = ""; 83 | this.show_success( 84 | "Success! Added targets:
    " + 85 | json.targets.map(tgt => `
  • ${tgt}
  • `).join("") + 86 | "
" 87 | ); 88 | this.update_targets(); 89 | } else { 90 | this.show_error( 91 | `Error! Could not add target ${json.target}: ` + 92 | json.error 93 | ); 94 | setTimeout(() => $('#add_tgt_name').focus(), 50); 95 | } 96 | }, 97 | clear_stats: async function() { 98 | var response = await this.$http.delete('/api/stats'); 99 | var json = await response.json(); 100 | if (json.success) { 101 | this.show_success( 102 | "Success!Stats are cleared." 103 | ); 104 | this.update_targets(); 105 | } 106 | }, 107 | show_success: function(msg) { 108 | this.success_msg = msg; 109 | setTimeout(function(vue){ 110 | vue.success_msg = ""; 111 | }, 5000, this); 112 | }, 113 | show_error: function(msg) { 114 | this.error_msg = msg; 115 | setTimeout(function(vue){ 116 | vue.error_msg = ""; 117 | }, 5000, this); 118 | }, 119 | on_btn_compare: function() { 120 | if (!this.comparing) { 121 | this.comparing = true; 122 | this.success_msg = "Select targets, then press compare again"; 123 | } else { 124 | var compare_targets = $("input[name=compare_target]:checked").map((_, el) => el.value).toArray(); 125 | if (compare_targets.length == 0) { 126 | this.show_error("Please select a few targets to compare."); 127 | } else if (compare_targets.length > 3) { 128 | this.show_error("We can only compare up to three targets at once."); 129 | } else { 130 | this.success_msg = ""; 131 | this.comparing = false; 132 | window.open( 133 | `/histogram/${this.hostname}/${compare_targets[0]}.png?` + 134 | compare_targets.slice(1).map(el => `compare=${el}`).join('&') 135 | ); 136 | } 137 | } 138 | }, 139 | show_route_for_target: function(target) { 140 | this.route_target = target; 141 | $('#routeModal').modal("show"); 142 | }, 143 | target_from_route_hop: function(hop) { 144 | this.add_tgt_name = hop.name; 145 | this.add_tgt_addr = hop.address; 146 | $('#routeModal').modal("hide"); 147 | } 148 | }, 149 | created: function() { 150 | var self = this; 151 | window.setInterval(function(vue){ 152 | if( new Date() - vue.last_update > 29500 ){ 153 | vue.update_targets(); 154 | } 155 | }, 1000, this); 156 | $(window).keydown(function(ev){ 157 | if (ev.ctrlKey && ev.key === "f") { 158 | ev.preventDefault(); 159 | $("#inpsearch").focus(); 160 | } 161 | else if (ev.key === "Escape") { 162 | $("#inpsearch").blur(); 163 | self.search = ""; 164 | } 165 | }); 166 | }, 167 | watch: { 168 | search: function(search) { 169 | localStorage.setItem("meshping_search", search); 170 | this.reapply_filters(); 171 | }, 172 | targets_all: function() { 173 | this.reapply_filters(); 174 | } 175 | }, 176 | filters: { 177 | prettyFloat: function(value) { 178 | if (value === undefined || typeof value.toFixed !== 'function') { 179 | return '—'; 180 | } 181 | return value.toFixed(2); 182 | } 183 | } 184 | }); 185 | --------------------------------------------------------------------------------