LabCodeHub öffentliche Ansicht
Anmelden
Küpper / netcheck öffentlich

erster Commit

R Rüdiger Küpper <rpr@9it.de> committete am 02.09.2026 12:47
9ea45c1f20f5945a2d31e71cf334ef4274c595a0
Eltern: keine (erster Commit)
Dateien zu diesem Stand
7 geänderte Datei(en) +1221 −0
Kontextzeilen: 0 1 2 3 10
hinzugefügt .gitignore
+3 −0 Datei ansehen
@@ -0,0 +1,3 @@
1 +
data/
2 +
netcheck.log
3 +
netcheck.conf
\ No newline at end of file
hinzugefügt README.md
+314 −0 Datei ansehen
@@ -0,0 +1,314 @@
1 +
# netcheck — Langzeit-Monitoring der IPv4-/IPv6-Erreichbarkeit
2 +
3 +
Kleines, abhängigkeitsarmes Shell-Script, das über Tage oder Wochen protokolliert,
4 +
**wann** Verbindungsprobleme auftreten und **welcher Art** sie sind. Gedacht für
5 +
Fälle, in denen ein Kunde "immer mal wieder" Störungen meldet, sich aber niemand
6 +
zum Zeitpunkt des Fehlers am Rechner befindet.
7 +
8 +
Pro Durchlauf wird für jedes Ziel und jede Protokollfamilie gemessen:
9 +
10 +
- **curl-Timing** — DNS-Auflösung, TCP-Connect, TLS-Handshake, Time-to-first-byte,
11 +
  Gesamtdauer, HTTP-Status und die tatsächlich kontaktierte IP
12 +
- **mtr-Pfadmessung** — Paketverlust und Latenz pro Hop, im TCP-Modus auf Port 443
13 +
14 +
Ergebnis ist eine CSV-Datei mit einer Zeile je Ziel und Protokoll sowie —
15 +
ausschließlich bei auffälligen Läufen — ein Rohlog mit der vollständigen
16 +
mtr-Ausgabe und einer längeren Nachmessung.
17 +
18 +
---
19 +
20 +
## Warum diese Kombination
21 +
22 +
Die beiden Messungen beantworten unterschiedliche Fragen, und erst zusammen
23 +
grenzen sie eine Störung ein:
24 +
25 +
| Beobachtung | Interpretation |
26 +
|---|---|
27 +
| curl schlägt fehl, mtr erreicht das Ziel sauber | Netzpfad in Ordnung, Problem liegt auf Anwendungsebene — Webserver, TLS, WAF, Rate-Limit |
28 +
| curl und mtr scheitern gemeinsam | Netzproblem, mtr zeigt im Rohlog den Hop, ab dem es abreißt |
29 +
| nur IPv6 betroffen, IPv4 läuft | Dual-Stack-Problem — fehlende Route, kaputte Router-Advertisements, PMTU |
30 +
| hohe `tls_ms` bei normalem `connect_ms` | TLS-Handshake ist langsam, nicht die Leitung |
31 +
| hohe `ttfb_ms` bei normalem `tls_ms` | Server denkt lange nach, Netz ist unschuldig |
32 +
| Verlust auf einem Zwischenhop, Ziel aber 0 % | Meist harmlos: der Router priorisiert nur seine eigenen ICMP-Antworten herunter |
33 +
34 +
Die letzte Zeile ist der häufigste Fehlschluss beim Lesen von mtr-Ausgaben.
35 +
Relevant ist Verlust nur, wenn er sich **ab einem Hop bis zum Ziel durchzieht**.
36 +
37 +
---
38 +
39 +
## Installation
40 +
41 +
### Voraussetzungen
42 +
43 +
- `bash`, `curl`, `awk`, `find` — auf macOS und jedem Linux vorhanden
44 +
- `mtr` — muss nachinstalliert werden:
45 +
  - macOS: `brew install mtr`
46 +
  - Debian/Ubuntu: `sudo apt install mtr-tiny`
47 +
  - RHEL/Rocky: `sudo dnf install mtr`
48 +
49 +
Ohne `mtr` läuft das Script weiter, zeichnet dann aber nur die curl-Messungen auf.
50 +
51 +
### Dateien ablegen
52 +
53 +
```bash
54 +
sudo mkdir -p /usr/local/netcheck
55 +
sudo cp netcheck.sh /usr/local/netcheck/
56 +
sudo chmod +x /usr/local/netcheck/netcheck.sh
57 +
sudo cp netcheck.conf.example /usr/local/netcheck/netcheck.conf
58 +
sudo vi /usr/local/netcheck/netcheck.conf
59 +
```
60 +
61 +
### Rechte
62 +
63 +
`mtr` benötigt Raw Sockets und damit Root-Rechte. Drei Varianten, absteigend
64 +
nach Sauberkeit:
65 +
66 +
1. **Als root per Scheduler starten** (empfohlen, siehe unten) — dann ist nichts
67 +
   weiter zu tun.
68 +
2. **Passwortloses sudo nur für mtr.** Das Script erkennt das automatisch und
69 +
   ruft `sudo -n mtr` auf. Regel anlegen mit `sudo visudo -f /etc/sudoers.d/netcheck`:
70 +
71 +
   ```
72 +
   deinbenutzer ALL=(root) NOPASSWD: /opt/homebrew/bin/mtr
73 +
   ```
74 +
75 +
   Pfad vorher mit `command -v mtr` prüfen — unter Linux meist `/usr/bin/mtr`.
76 +
3. **Ohne Root** — dann bleiben die mtr-Spalten leer und es werden nur
77 +
   curl-Messungen aufgezeichnet. Für eine erste Eingrenzung reicht das oft.
78 +
79 +
### Prüfen
80 +
81 +
```bash
82 +
/usr/local/netcheck/netcheck.sh selftest
83 +
```
84 +
85 +
Der Selbsttest meldet fehlende Werkzeuge, die Rechtelage und macht einen
86 +
Testabruf pro Ziel und Protokollfamilie.
87 +
88 +
---
89 +
90 +
## Betrieb
91 +
92 +
| Befehl | Wirkung |
93 +
|---|---|
94 +
| `netcheck.sh once` | einmaliger Lauf mit Ausgabe im Terminal |
95 +
| `netcheck.sh run` | einmaliger Lauf, still — für cron, launchd, systemd |
96 +
| `netcheck.sh daemon` | Endlosschleife im Abstand von `INTERVAL` |
97 +
| `netcheck.sh stop` | beendet einen laufenden `daemon` sauber |
98 +
| `netcheck.sh summary` | Auswertung der gesammelten Daten |
99 +
| `netcheck.sh selftest` | prüft Voraussetzungen, Rechte und Erreichbarkeit |
100 +
101 +
### Einzelner Lauf mit Ausgabe
102 +
103 +
```bash
104 +
sudo /usr/local/netcheck/netcheck.sh once
105 +
```
106 +
107 +
### Dauerbetrieb im Vordergrund
108 +
109 +
Praktisch, um über Nacht in einer Terminal-Session mitlaufen zu lassen:
110 +
111 +
```bash
112 +
sudo /usr/local/netcheck/netcheck.sh daemon
113 +
```
114 +
115 +
### Sauber beenden
116 +
117 +
**Ctrl+C** im Terminal, oder aus einer zweiten Shell:
118 +
119 +
```bash
120 +
sudo /usr/local/netcheck/netcheck.sh stop
121 +
```
122 +
123 +
Beides läuft über denselben Weg: das Script merkt sich den Stopp-Wunsch,
124 +
beendet die gerade laufende Messung und **verwirft sie ersatzlos** — eine
125 +
abgebrochene Messung landet nicht in der CSV. Das ist der entscheidende Punkt,
126 +
denn eine mitten im Lauf abgeschossene curl- oder mtr-Messung sieht in den Daten
127 +
exakt wie ein echter Ausfall aus und würde die Auswertung verfälschen. Im
128 +
Betriebslog steht stattdessen eine `SKIP`-Zeile.
129 +
130 +
Ein zweites Ctrl+C erzwingt den sofortigen Abbruch. Dann kann eine unvollständige
131 +
Messung in den Rohlogs zurückbleiben — die CSV bleibt aber auch in diesem Fall
132 +
sauber, weil die Zeile erst nach vollständiger Messung geschrieben wird.
133 +
134 +
`netcheck.sh stop` findet die laufende Instanz über die Sperrdatei, wartet bis
135 +
zu 60 Sekunden auf ein sauberes Ende und greift erst danach härter durch. Eine
136 +
verwaiste Sperre nach einem Absturz räumt es ebenfalls auf.
137 +
138 +
Falls doch einmal etwas hängt — etwa nach einem `kill -9` —, hilft:
139 +
140 +
```bash
141 +
sudo pkill -9 -f 'netcheck.sh daemon'
142 +
sudo pkill -9 mtr
143 +
sudo rm -rf /usr/local/netcheck/data/.lock
144 +
```
145 +
146 +
### Verfälschte Zeilen nachträglich entfernen
147 +
148 +
Wurden durch einen harten Abbruch doch Scheinausfälle aufgezeichnet, lassen sie
149 +
sich gezielt löschen. Von einem Signal abgeschossene Messungen haben einen
150 +
`curl_exit` ab 128 (meist 130 für SIGINT):
151 +
152 +
```bash
153 +
cd /usr/local/netcheck/data/csv
154 +
for f in netcheck-*.csv; do
155 +
  awk -F, 'NR==1 || $8+0 < 128' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
156 +
done
157 +
```
158 +
159 +
Alternativ alle Zeilen eines bestimmten Laufs anhand der `runid` (Spalte 3, im
160 +
Format `JJJJMMTT-HHMMSS`) entfernen:
161 +
162 +
```bash
163 +
awk -F, '$3 != "20260902-143000"' netcheck-2026-09.csv > tmp && mv tmp netcheck-2026-09.csv
164 +
```
165 +
166 +
Vorher mit `grep` prüfen, was die Filter treffen würden — die CSV ist die
167 +
Beweislage, versehentlich gelöschte echte Ausfälle sind nicht rekonstruierbar.
168 +
169 +
### Dauerbetrieb per Scheduler
170 +
171 +
**macOS (launchd):**
172 +
173 +
```bash
174 +
sudo cp cloud.tytik.netcheck.plist /Library/LaunchDaemons/
175 +
sudo chown root:wheel /Library/LaunchDaemons/cloud.tytik.netcheck.plist
176 +
sudo chmod 644 /Library/LaunchDaemons/cloud.tytik.netcheck.plist
177 +
sudo launchctl load -w /Library/LaunchDaemons/cloud.tytik.netcheck.plist
178 +
```
179 +
180 +
Vorher den Pfad in `ProgramArguments` und das Intervall in `StartInterval`
181 +
kontrollieren. Beenden mit `launchctl unload -w …`.
182 +
183 +
**Linux (systemd):**
184 +
185 +
```bash
186 +
sudo cp netcheck.service netcheck.timer /etc/systemd/system/
187 +
sudo systemctl daemon-reload
188 +
sudo systemctl enable --now netcheck.timer
189 +
systemctl list-timers netcheck.timer
190 +
```
191 +
192 +
**Linux (cron), falls systemd nicht gewünscht ist:**
193 +
194 +
```
195 +
*/5 * * * * root /usr/local/netcheck/netcheck.sh run
196 +
```
197 +
198 +
Überlappende Läufe sind ausgeschlossen: das Script setzt eine Sperre und
199 +
überspringt einen Start, solange der vorherige Lauf noch läuft.
200 +
201 +
---
202 +
203 +
## Konfiguration
204 +
205 +
Alle Werte stehen in `netcheck.conf` neben dem Script (Vorlage:
206 +
`netcheck.conf.example`). Die wichtigsten:
207 +
208 +
| Variable | Default | Bedeutung |
209 +
|---|---|---|
210 +
| `TARGETS` | `kreuzfahrten.de` | Ziele, durch Leerzeichen getrennt, ohne `https://` |
211 +
| `FAMILIES` | `4 6` | zu prüfende Protokollfamilien |
212 +
| `OUTDIR` | `./data` | Ablage für CSV, Rohlogs und Logfile |
213 +
| `INTERVAL` | `300` | Abstand im `daemon`-Modus in Sekunden |
214 +
| `MTR_CYCLES` | `10` | Pings pro Hop im Normalfall |
215 +
| `MTR_ANOMALY_CYCLES` | `30` | Pings pro Hop bei der Nachmessung |
216 +
| `LOSS_WARN` | `5` | Paketverlust zum Ziel in %, ab dem ein Lauf auffällig ist |
217 +
| `RTT_WARN` | `150` | mittlere Latenz zum Ziel in ms |
218 +
| `TOTAL_WARN` | `3000` | curl-Gesamtzeit in ms |
219 +
| `SNAPSHOT_ON_ANOMALY` | `0` | bei Auffälligkeit zusätzlich den lokalen Netzzustand mitschreiben |
220 +
| `RAW_RETENTION_DAYS` | `30` | Rohlogs älter als N Tage löschen, `0` = nie |
221 +
222 +
**Empfehlung zur Zielauswahl:** neben dem eigentlichen Kundenziel ein bis zwei
223 +
stabile Referenzziele aufnehmen, etwa `one.one.one.one` und eine große deutsche
224 +
Site. Fällt nur das Kundenziel aus, liegt es am Ziel; fallen alle gleichzeitig
225 +
aus, liegt es an der Leitung des Kunden. Diese Unterscheidung erspart die
226 +
längsten Diskussionen.
227 +
228 +
**`SNAPSHOT_ON_ANOMALY=1`** lohnt sich, wenn ein Problem am Client oder am
229 +
Kundenrouter vermutet wird. Das Script schreibt dann bei jeder Auffälligkeit
230 +
Default-Routen, Router-Advertisements und Interface-Adressen mit — genau die
231 +
Angaben, an denen etwa eine verschwundene IPv6-Default-Route sofort sichtbar wird.
232 +
233 +
---
234 +
235 +
## Auswertung
236 +
237 +
```bash
238 +
/usr/local/netcheck/netcheck.sh summary          # alle vorhandenen Monate
239 +
/usr/local/netcheck/netcheck.sh summary 2026-09  # nur September 2026
240 +
```
241 +
242 +
Ausgabe: Messzeitraum, je Ziel und Protokoll die Zahl der Läufe, Fehler und
243 +
Auffälligkeiten samt Durchschnitts- und Maximalwerten, die häufigsten Ursachen
244 +
sowie eine Verteilung der Auffälligkeiten nach Tag und nach Tageszeit. Die
245 +
Tageszeit-Verteilung ist der schnellste Weg zu einem Muster — Häufungen zur
246 +
vollen Stunde deuten auf Backups oder Cronjobs, abendliche Häufungen auf
247 +
Überlast im Zugangsnetz, gleichmäßige Verteilung eher auf ein Hardware- oder
248 +
Routingproblem.
249 +
250 +
### Rohdaten
251 +
252 +
`data/csv/netcheck-YYYY-MM.csv` — eine Zeile je Ziel und Protokoll, direkt in
253 +
Excel, LibreOffice oder Grafana einlesbar. Spalten:
254 +
255 +
| Spalte | Bedeutung |
256 +
|---|---|
257 +
| `ts`, `epoch`, `runid` | Zeitstempel des Laufs |
258 +
| `target`, `family` | Ziel und Protokollfamilie (4 oder 6) |
259 +
| `resolved_ip` | tatsächlich kontaktierte IP — verrät CDN-Wechsel |
260 +
| `curl_ok`, `curl_exit`, `http_code` | Erfolg, curl-Exitcode, HTTP-Status |
261 +
| `dns_ms`, `connect_ms`, `tls_ms`, `ttfb_ms`, `total_ms` | curl-Timing, kumulativ |
262 +
| `mtr_ok`, `mtr_loss_pct`, `mtr_avg_ms`, `mtr_worst_ms` | Messwerte am Ziel-Hop |
263 +
| `mtr_max_hop_loss` | höchster Verlust auf irgendeinem Hop |
264 +
| `mtr_hops`, `dest_reached` | Hop-Anzahl, ob das Ziel geantwortet hat |
265 +
| `anomaly`, `reason` | Bewertung des Laufs und Klartextbegründung |
266 +
| `rawfile` | Pfad zum Rohlog, relativ zu `data/raw/` |
267 +
268 +
Die curl-Zeiten sind **kumulativ**: `tls_ms` enthält `connect_ms`, dieses enthält
269 +
`dns_ms`. Die Dauer des TLS-Handshakes allein ist also `tls_ms − connect_ms`.
270 +
271 +
`data/raw/YYYY-MM-DD/…` — Rohlogs auffälliger Läufe mit vollständiger
272 +
mtr-Ausgabe, Kurzlauf und Nachmessung.
273 +
274 +
`data/netcheck.log` — Betriebsprotokoll mit einer Zeile je Auffälligkeit.
275 +
276 +
### Schnelle Handgriffe
277 +
278 +
```bash
279 +
# Alle Ausfälle chronologisch
280 +
awk -F, '$7==0 {print $1, $4, "IPv"$5, $23}' data/csv/*.csv
281 +
282 +
# Nur IPv6-Probleme
283 +
awk -F, '$5==6 && $22==1 {print $1, $23}' data/csv/*.csv
284 +
285 +
# Latenzverlauf eines Ziels für ein Diagramm
286 +
awk -F, '$4=="kreuzfahrten.de" && $5==4 && $15==1 {print $1","$17}' data/csv/*.csv
287 +
```
288 +
289 +
---
290 +
291 +
## Wartung
292 +
293 +
Rohlogs werden nach `RAW_RETENTION_DAYS` automatisch gelöscht, CSV-Dateien
294 +
bleiben liegen — sie sind klein (rund 200 Byte pro Messung, bei zwei Zielen im
295 +
Dual-Stack und 5-Minuten-Takt etwa 6 MB im Monat) und bilden die eigentliche
296 +
Beweislage gegenüber dem Provider.
297 +
298 +
Belastung: ein Lauf erzeugt pro Ziel und Familie einen HTTPS-HEAD-Request und
299 +
`MTR_CYCLES` Pakete je Hop. Bei den Defaults sind das wenige Kilobyte alle fünf
300 +
Minuten — unbedenklich auch auf einer schwachen Leitung.
301 +
302 +
---
303 +
304 +
## Grenzen
305 +
306 +
Das Script misst vom Standort aus, an dem es läuft. Meldet der Kunde Probleme,
307 +
die hier nicht auftauchen, liegt das Problem entweder beim Kunden selbst oder
308 +
tritt nur unter Last auf. In dem Fall gehört eine zweite Instanz auf einen
309 +
Rechner im Netz des Kunden — die CSV-Dateien beider Instanzen lassen sich über
310 +
die Zeitstempel direkt nebeneinanderlegen.
311 +
312 +
Ebenfalls nicht abgedeckt: Durchsatzmessungen, DNS-Vergleich zwischen mehreren
313 +
Resolvern und die Analyse einzelner TCP-Verbindungen. Lässt sich alles ergänzen,
314 +
war für diesen Zweck aber bewusst nicht Teil des Umfangs.
\ No newline at end of file
hinzugefügt cloud.tytik.netcheck.plist
+52 −0 Datei ansehen
@@ -0,0 +1,52 @@
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +
<!--
4 +
  launchd-Job fuer netcheck.sh auf macOS.
5 +
6 +
  Installation (als root, damit mtr Raw Sockets oeffnen darf):
7 +
    sudo cp cloud.tytik.netcheck.plist /Library/LaunchDaemons/
8 +
    sudo chown root:wheel /Library/LaunchDaemons/cloud.tytik.netcheck.plist
9 +
    sudo chmod 644 /Library/LaunchDaemons/cloud.tytik.netcheck.plist
10 +
    sudo launchctl load -w /Library/LaunchDaemons/cloud.tytik.netcheck.plist
11 +
12 +
  Deinstallation:
13 +
    sudo launchctl unload -w /Library/LaunchDaemons/cloud.tytik.netcheck.plist
14 +
15 +
  Pfad in ProgramArguments an den tatsaechlichen Ablageort anpassen.
16 +
-->
17 +
<plist version="1.0">
18 +
<dict>
19 +
    <key>Label</key>
20 +
    <string>cloud.tytik.netcheck</string>
21 +
22 +
    <key>ProgramArguments</key>
23 +
    <array>
24 +
        <string>/usr/local/netcheck/netcheck.sh</string>
25 +
        <string>run</string>
26 +
    </array>
27 +
28 +
    <!-- alle 300 Sekunden -->
29 +
    <key>StartInterval</key>
30 +
    <integer>300</integer>
31 +
32 +
    <key>RunAtLoad</key>
33 +
    <true/>
34 +
35 +
    <key>StandardOutPath</key>
36 +
    <string>/var/log/netcheck-launchd.log</string>
37 +
    <key>StandardErrorPath</key>
38 +
    <string>/var/log/netcheck-launchd.log</string>
39 +
40 +
    <key>EnvironmentVariables</key>
41 +
    <dict>
42 +
        <key>PATH</key>
43 +
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
44 +
    </dict>
45 +
46 +
    <!-- Verhindert, dass der Job bei Ressourcenknappheit gedrosselt wird -->
47 +
    <key>LowPriorityIO</key>
48 +
    <false/>
49 +
    <key>Nice</key>
50 +
    <integer>0</integer>
51 +
</dict>
52 +
</plist>
hinzugefügt netcheck.conf.example
+73 −0 Datei ansehen
@@ -0,0 +1,73 @@
1 +
# netcheck.conf - Konfiguration fuer netcheck.sh
2 +
#
3 +
# Diese Datei nach "netcheck.conf" kopieren und anpassen. Sie wird vom Script
4 +
# eingelesen (sourced) und ueberschreibt die eingebauten Defaults.
5 +
# Environment-Variablen beim Aufruf haben Vorrang vor dieser Datei nicht -
6 +
# die Datei gewinnt, weil sie zuletzt gelesen wird. Wer einzelne Werte
7 +
# temporaer uebersteuern will, kommentiert sie hier aus.
8 +
9 +
# --- Was gemessen wird ------------------------------------------------------
10 +
11 +
# Zu pruefende Ziele, durch Leerzeichen getrennt, ohne https://
12 +
# Sinnvoll ist eine Mischung: das eigentliche Kundenziel plus ein bis zwei
13 +
# stabile Referenzziele. Faellt nur das Kundenziel aus, liegt es am Ziel.
14 +
# Fallen alle gleichzeitig aus, liegt es an der Leitung des Kunden.
15 +
TARGETS="tytik.cloud www.heise.de one.one.one.one"
16 +
17 +
# Protokollfamilien
18 +
FAMILIES="4 6"
19 +
20 +
# Pfad, der per HTTPS abgerufen wird
21 +
CURL_PATH="/"
22 +
23 +
# HEAD spart Traffic, GET misst realistischer. Manche WAFs mogen HEAD nicht -
24 +
# wenn dauerhaft 405 oder 403 kommt, auf GET wechseln.
25 +
CURL_METHOD="HEAD"
26 +
27 +
# --- Zeitverhalten ----------------------------------------------------------
28 +
29 +
# Abstand zwischen den Laeufen im daemon-Modus (Sekunden).
30 +
# Bei cron/launchd steuert das Intervall dort, nicht hier.
31 +
INTERVAL=300
32 +
33 +
# mtr-Zyklen im Normalfall und bei einer Auffaelligkeit
34 +
MTR_CYCLES=10
35 +
MTR_ANOMALY_CYCLES=30
36 +
37 +
CURL_CONNECT_TIMEOUT=10
38 +
CURL_MAX_TIME=20
39 +
40 +
# --- Schwellwerte -----------------------------------------------------------
41 +
# Ab diesen Werten gilt ein Lauf als auffaellig, wird im Log vermerkt und
42 +
# erzeugt ein Rohlog mit ausfuehrlicher mtr-Nachmessung.
43 +
44 +
LOSS_WARN=5          # Paketverlust zum Ziel in Prozent
45 +
RTT_WARN=150         # mittlere Latenz zum Ziel in ms
46 +
TOTAL_WARN=3000      # curl-Gesamtzeit in ms
47 +
48 +
# --- Ablage -----------------------------------------------------------------
49 +
50 +
# Standard ist ein Unterverzeichnis "data" neben dem Script.
51 +
# Fuer den Dauerbetrieb besser ein fester Pfad:
52 +
# OUTDIR="/var/log/netcheck"
53 +
54 +
# Rohlogs aelter als N Tage werden geloescht. 0 = nie loeschen.
55 +
RAW_RETENTION_DAYS=30
56 +
57 +
# Bei Auffaelligkeit zusaetzlich den lokalen Netzzustand mitschreiben
58 +
# (Default-Routen, Router-Advertisements, Interface-Adressen).
59 +
# Sehr hilfreich, wenn der Verdacht auf ein Problem am Client oder am
60 +
# Kundenrouter besteht - etwa eine verschwundene IPv6-Default-Route.
61 +
SNAPSHOT_ON_ANOMALY=1
62 +
63 +
# --- Feinheiten -------------------------------------------------------------
64 +
65 +
# mtr-Messmodus. TCP/443 kommt durch die meisten Filter. Alternativen:
66 +
#   "-T -P 443"   TCP SYN auf 443 (Default, empfohlen)
67 +
#   "-u"          UDP
68 +
#   ""            ICMP
69 +
MTR_MODE="-T -P 443"
70 +
71 +
# "-b" zeigt Hostname und IP je Hop. "-n" verzichtet auf Reverse-DNS und
72 +
# macht die Messung schneller, kostet aber Lesbarkeit.
73 +
MTR_EXTRA="-b"
hinzugefügt netcheck.service
+24 −0 Datei ansehen
@@ -0,0 +1,24 @@
1 +
# systemd-Service fuer netcheck.sh (Linux)
2 +
#
3 +
# Installation:
4 +
#   sudo cp netcheck.service netcheck.timer /etc/systemd/system/
5 +
#   sudo systemctl daemon-reload
6 +
#   sudo systemctl enable --now netcheck.timer
7 +
#
8 +
# Status und Logs:
9 +
#   systemctl list-timers netcheck.timer
10 +
#   journalctl -u netcheck.service -f
11 +
12 +
[Unit]
13 +
Description=netcheck - IPv4/IPv6 Verbindungsmonitoring
14 +
Wants=network-online.target
15 +
After=network-online.target
16 +
17 +
[Service]
18 +
Type=oneshot
19 +
# Als root, damit mtr Raw Sockets oeffnen darf
20 +
User=root
21 +
ExecStart=/usr/local/netcheck/netcheck.sh run
22 +
# Ein Lauf darf nie laenger dauern als das Timer-Intervall
23 +
TimeoutStartSec=240
24 +
Nice=5
hinzugefügt netcheck.sh
+743 −0 Datei ansehen
@@ -0,0 +1,743 @@
1 +
#!/usr/bin/env bash
2 +
#
3 +
# netcheck.sh - Langzeit-Monitoring der IPv4-/IPv6-Erreichbarkeit
4 +
#
5 +
# Misst pro Durchlauf und pro Ziel jeweils fuer IPv4 und IPv6:
6 +
#   - curl-Timing (DNS, TCP-Connect, TLS-Handshake, TTFB, gesamt, HTTP-Status)
7 +
#   - mtr-Pfadmessung (Loss und Latenz pro Hop, TCP-Modus auf Port 443)
8 +
#
9 +
# Schreibt eine CSV-Zeile pro Ziel/Protokoll und legt die vollstaendige
10 +
# mtr-Ausgabe als Rohlog ab, wenn eine Messung auffaellig war.
11 +
#
12 +
# Laeuft auf macOS und Linux. Bewusst bash-3.2-kompatibel (macOS-Bordmittel).
13 +
#
14 +
# Aufruf:
15 +
#   ./netcheck.sh once              einmaliger Lauf mit Ausgabe auf stdout
16 +
#   ./netcheck.sh run               einmaliger Lauf, still (fuer cron/launchd)
17 +
#   ./netcheck.sh daemon            Endlosschleife im Abstand von INTERVAL
18 +
#   ./netcheck.sh stop              beendet einen laufenden daemon sauber
19 +
#   ./netcheck.sh summary [YYYY-MM] Auswertung der gesammelten Daten
20 +
#   ./netcheck.sh selftest          prueft Voraussetzungen und Konfiguration
21 +
#
22 +
# Version 1.2
23 +
#
24 +
25 +
set -u
26 +
27 +
# PATH explizit setzen: cron und launchd starten mit minimalem Environment,
28 +
# Homebrew-Tools (mtr) liegen dort sonst ausserhalb des Suchpfads.
29 +
PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
30 +
export PATH
31 +
export LC_ALL=C
32 +
33 +
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
34 +
35 +
# ---------------------------------------------------------------------------
36 +
# Konfiguration (per netcheck.conf neben dem Script oder per Environment)
37 +
# ---------------------------------------------------------------------------
38 +
39 +
# Zu pruefende Ziele, durch Leerzeichen getrennt. Hostnamen ohne Schema.
40 +
TARGETS="${TARGETS:-kreuzfahrten.de}"
41 +
42 +
# Protokollfamilien: "4 6", "4" oder "6"
43 +
FAMILIES="${FAMILIES:-4 6}"
44 +
45 +
# Ablageort fuer CSV, Rohlogs und Logfile
46 +
OUTDIR="${OUTDIR:-$SCRIPT_DIR/data}"
47 +
48 +
# Wartezeit zwischen den Laeufen im daemon-Modus (Sekunden)
49 +
INTERVAL="${INTERVAL:-300}"
50 +
51 +
# mtr: Anzahl Pings pro Hop im Normalfall bzw. bei Auffaelligkeit
52 +
MTR_CYCLES="${MTR_CYCLES:-10}"
53 +
MTR_ANOMALY_CYCLES="${MTR_ANOMALY_CYCLES:-30}"
54 +
55 +
# mtr-Modus. TCP auf 443 ist am aussagekraeftigsten, weil viele CDNs
56 +
# UDP-/ICMP-Traceroute verwerfen. "-b" zeigt Hostname und IP.
57 +
MTR_MODE="${MTR_MODE:--T -P 443}"
58 +
MTR_EXTRA="${MTR_EXTRA:--b}"
59 +
60 +
# curl
61 +
CURL_METHOD="${CURL_METHOD:-HEAD}"       # HEAD oder GET
62 +
CURL_PATH="${CURL_PATH:-/}"
63 +
CURL_CONNECT_TIMEOUT="${CURL_CONNECT_TIMEOUT:-10}"
64 +
CURL_MAX_TIME="${CURL_MAX_TIME:-20}"
65 +
CURL_UA="${CURL_UA:-netcheck/1.0 (Verbindungsmonitoring)}"
66 +
67 +
# Schwellwerte, ab denen ein Lauf als auffaellig gilt und ein Rohlog entsteht
68 +
LOSS_WARN="${LOSS_WARN:-5}"              # Paketverlust zum Ziel in Prozent
69 +
RTT_WARN="${RTT_WARN:-150}"              # mittlere Latenz zum Ziel in ms
70 +
TOTAL_WARN="${TOTAL_WARN:-3000}"         # curl-Gesamtzeit in ms
71 +
72 +
# Lokalen Netz-Snapshot (Routen, Router-Advertisements, Interface-Adressen)
73 +
# bei Auffaelligkeit mitschreiben. 1 = an, 0 = aus.
74 +
SNAPSHOT_ON_ANOMALY="${SNAPSHOT_ON_ANOMALY:-0}"
75 +
76 +
# Rohlogs aelter als N Tage loeschen. 0 = nie loeschen.
77 +
RAW_RETENTION_DAYS="${RAW_RETENTION_DAYS:-30}"
78 +
79 +
# Konfigurationsdatei ueberschreibt die Defaults oben
80 +
CONF="${NETCHECK_CONF:-$SCRIPT_DIR/netcheck.conf}"
81 +
if [ -f "$CONF" ]; then
82 +
    # shellcheck disable=SC1090
83 +
    . "$CONF"
84 +
fi
85 +
86 +
# Original-stderr auf FD 9 sichern. Waehrend einer mtr-Messung ist stderr in
87 +
# die Rohdatei umgeleitet; Meldungen aus dem Signalhandler wuerden dort landen
88 +
# statt im Terminal. Ueber FD 9 erreichen sie immer den Benutzer.
89 +
exec 9>&2
90 +
91 +
CSV_DIR="$OUTDIR/csv"
92 +
RAW_DIR="$OUTDIR/raw"
93 +
LOGFILE="$OUTDIR/netcheck.log"
94 +
LOCKDIR="$OUTDIR/.lock"
95 +
96 +
CSV_HEADER="ts,epoch,runid,target,family,resolved_ip,curl_ok,curl_exit,http_code,dns_ms,connect_ms,tls_ms,ttfb_ms,total_ms,mtr_ok,mtr_loss_pct,mtr_avg_ms,mtr_worst_ms,mtr_max_hop_loss,mtr_hops,dest_reached,anomaly,reason,rawfile"
97 +
98 +
VERBOSE=0
99 +
100 +
# Laufzeitzustand fuer die Signalbehandlung
101 +
STOP=0            # 1 = Abbruch angefordert, laufende Messung verwerfen
102 +
SIGCOUNT=0        # Anzahl empfangener Abbruchsignale
103 +
CHILD_PID=""      # PID des gerade laufenden Messprozesses
104 +
SLEEP_PID=""      # PID des laufenden sleep im daemon-Modus
105 +
LOCK_HELD=0
106 +
107 +
# ---------------------------------------------------------------------------
108 +
# Hilfsfunktionen
109 +
# ---------------------------------------------------------------------------
110 +
111 +
log() {
112 +
    printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*" >>"$LOGFILE"
113 +
    [ "$VERBOSE" -eq 1 ] && printf '%s\n' "$*" >&9
114 +
    return 0
115 +
}
116 +
117 +
die() {
118 +
    printf 'netcheck: %s\n' "$*" >&2
119 +
    exit 1
120 +
}
121 +
122 +
# Portabler Timeout-Wrapper: macOS hat kein timeout(1).
123 +
# Aufruf: run_timeout SEKUNDEN BEFEHL [ARGS...]
124 +
run_timeout() {
125 +
    _to_secs="$1"
126 +
    shift
127 +
    "$@" &
128 +
    _to_cmd=$!
129 +
    CHILD_PID="$_to_cmd"
130 +
    ( sleep "$_to_secs"; kill -TERM "$_to_cmd" 2>/dev/null ) &
131 +
    _to_watch=$!
132 +
    wait "$_to_cmd" 2>/dev/null
133 +
    _to_rc=$?
134 +
    CHILD_PID=""
135 +
    kill -TERM "$_to_watch" 2>/dev/null
136 +
    wait "$_to_watch" 2>/dev/null
137 +
    return "$_to_rc"
138 +
}
139 +
140 +
# Sekunden (curl-Format, z.B. 0,123456 oder 0.123456) in Millisekunden
141 +
to_ms() {
142 +
    printf '%s' "$1" | awk '{ gsub(",", ".", $0); printf "%.1f", $0 * 1000 }'
143 +
}
144 +
145 +
csv_escape() {
146 +
    # Kommas und Anfuehrungszeichen aus Freitextfeldern entfernen
147 +
    printf '%s' "$1" | tr ',"' '; '
148 +
}
149 +
150 +
have() { command -v "$1" >/dev/null 2>&1; }
151 +
152 +
# ---------------------------------------------------------------------------
153 +
# Messungen
154 +
# ---------------------------------------------------------------------------
155 +
156 +
# curl-Timing. Setzt die globalen Variablen c_ok c_exit c_code c_dns c_con
157 +
# c_tls c_ttfb c_total c_ip
158 +
measure_curl() {
159 +
    _target="$1"
160 +
    _fam="$2"
161 +
    _method_opt="-I -o /dev/null"
162 +
    [ "$CURL_METHOD" = "GET" ] && _method_opt="-o /dev/null"
163 +
164 +
    _fmt='%{http_code} %{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total} %{remote_ip}'
165 +
    _out=""
166 +
    # shellcheck disable=SC2086
167 +
    _out="$(curl "-$_fam" $_method_opt -sS \
168 +
        --connect-timeout "$CURL_CONNECT_TIMEOUT" \
169 +
        --max-time "$CURL_MAX_TIME" \
170 +
        -A "$CURL_UA" \
171 +
        -w "$_fmt" \
172 +
        "https://${_target}${CURL_PATH}" 2>/dev/null)"
173 +
    c_exit=$?
174 +
175 +
    if [ -n "$_out" ]; then
176 +
        set -- $_out
177 +
        c_code="${1:-000}"
178 +
        c_dns="$(to_ms "${2:-0}")"
179 +
        c_con="$(to_ms "${3:-0}")"
180 +
        c_tls="$(to_ms "${4:-0}")"
181 +
        c_ttfb="$(to_ms "${5:-0}")"
182 +
        c_total="$(to_ms "${6:-0}")"
183 +
        c_ip="${7:-}"
184 +
    else
185 +
        c_code="000"; c_dns=""; c_con=""; c_tls=""; c_ttfb=""; c_total=""; c_ip=""
186 +
    fi
187 +
188 +
    if [ "$c_exit" -eq 0 ] && [ "$c_code" != "000" ]; then
189 +
        c_ok=1
190 +
    else
191 +
        c_ok=0
192 +
    fi
193 +
}
194 +
195 +
# mtr-Pfadmessung. Setzt m_ok m_loss m_avg m_worst m_maxhoploss m_hops
196 +
# m_dest_reached und schreibt die Rohausgabe nach "$1"
197 +
measure_mtr() {
198 +
    _target="$1"
199 +
    _fam="$2"
200 +
    _cycles="$3"
201 +
    _rawfile="$4"
202 +
203 +
    m_ok=0; m_loss=""; m_avg=""; m_worst=""; m_maxhoploss=""; m_hops=""
204 +
    m_dest_reached=0
205 +
206 +
    have mtr || { log "WARN mtr nicht gefunden - Pfadmessung uebersprungen"; return 1; }
207 +
208 +
    # mtr braucht Raw Sockets. Als root direkt, sonst passwortloses sudo.
209 +
    _mtr="mtr"
210 +
    if [ "$(id -u)" -ne 0 ]; then
211 +
        if sudo -n true 2>/dev/null; then
212 +
            _mtr="sudo -n mtr"
213 +
        fi
214 +
    fi
215 +
216 +
    # Grosszuegiger Timeout: ein Zyklus pro Sekunde plus Puffer
217 +
    _timeout=$(( _cycles + 30 ))
218 +
219 +
    # shellcheck disable=SC2086
220 +
    run_timeout "$_timeout" $_mtr "-$_fam" $MTR_MODE $MTR_EXTRA -r -w \
221 +
        -c "$_cycles" "$_target" >"$_rawfile" 2>&1
222 +
    _rc=$?
223 +
224 +
    if [ "$_rc" -ne 0 ] && [ ! -s "$_rawfile" ]; then
225 +
        log "WARN mtr -$_fam $_target fehlgeschlagen (exit $_rc)"
226 +
        return 1
227 +
    fi
228 +
229 +
    # Aus dem Report die Hop-Zeilen auswerten. Die letzten sieben Spalten sind
230 +
    # immer Loss% Snt Last Avg Best Wrst StDev - unabhaengig davon, wie breit
231 +
    # die Hostspalte ist. Deshalb wird von hinten indiziert.
232 +
    _parsed="$(awk '
233 +
        BEGIN { maxloss = 0; hops = 0; last_loss = 0; last_avg = 0; last_wrst = 0; last_host = "???" }
234 +
        /\|--/ {
235 +
            loss = $(NF-6); sub("%", "", loss);
236 +
            avg  = $(NF-3);
237 +
            wrst = $(NF-1);
238 +
            host = $2;
239 +
            hops++;
240 +
            if (loss+0 > maxloss+0) maxloss = loss;
241 +
            last_loss = loss; last_avg = avg; last_wrst = wrst; last_host = host;
242 +
        }
243 +
        END {
244 +
            if (hops == 0) { print "NA"; exit }
245 +
            reached = (last_host == "???" || last_loss+0 >= 100) ? 0 : 1;
246 +
            printf "%s %s %s %s %d %d\n", last_loss, last_avg, last_wrst, maxloss, hops, reached;
247 +
        }' "$_rawfile")"
248 +
249 +
    [ "$_parsed" = "NA" ] && { log "WARN mtr -$_fam $_target lieferte keine Hops"; return 1; }
250 +
251 +
    set -- $_parsed
252 +
    m_loss="${1:-}"; m_avg="${2:-}"; m_worst="${3:-}"; m_maxhoploss="${4:-0}"
253 +
    m_hops="${5:-0}"; m_dest_reached="${6:-0}"
254 +
    m_ok=1
255 +
    return 0
256 +
}
257 +
258 +
# Lokaler Netz-Snapshot bei Auffaelligkeit
259 +
snapshot() {
260 +
    _file="$1"
261 +
    {
262 +
        echo "=== $(date '+%Y-%m-%dT%H:%M:%S%z') lokaler Netz-Snapshot ==="
263 +
        if [ "$(uname -s)" = "Darwin" ]; then
264 +
            echo "--- scutil --nwi"; scutil --nwi 2>&1
265 +
            echo "--- netstat -rn -f inet6 | grep default"
266 +
            netstat -rn -f inet6 2>/dev/null | grep default
267 +
            echo "--- netstat -rn -f inet | grep default"
268 +
            netstat -rn -f inet 2>/dev/null | grep default
269 +
            echo "--- ndp -rn"; ndp -rn 2>&1
270 +
            echo "--- ifconfig (global)"; ifconfig 2>/dev/null | grep -E '^[a-z]|inet '
271 +
        elif have ip; then
272 +
            echo "--- ip -6 route"; ip -6 route 2>&1
273 +
            echo "--- ip -4 route"; ip -4 route 2>&1
274 +
            echo "--- ip -brief addr"; ip -brief addr 2>&1
275 +
            echo "--- ip -6 neigh"; ip -6 neigh 2>&1
276 +
        else
277 +
            echo "--- netstat -rn (iproute2 nicht vorhanden)"
278 +
            netstat -rn 2>&1
279 +
            echo "--- ifconfig"; ifconfig 2>&1 | grep -E '^[a-z]|inet'
280 +
        fi
281 +
    } >>"$_file" 2>&1
282 +
}
283 +
284 +
# ---------------------------------------------------------------------------
285 +
# Ein Durchlauf
286 +
# ---------------------------------------------------------------------------
287 +
288 +
do_run() {
289 +
    _ts="$(date '+%Y-%m-%dT%H:%M:%S%z')"
290 +
    _epoch="$(date '+%s')"
291 +
    _runid="$(date '+%Y%m%d-%H%M%S')"
292 +
    _day="$(date '+%Y-%m-%d')"
293 +
    _month="$(date '+%Y-%m')"
294 +
    _csv="$CSV_DIR/netcheck-$_month.csv"
295 +
296 +
    [ -f "$_csv" ] || printf '%s\n' "$CSV_HEADER" >"$_csv"
297 +
    mkdir -p "$RAW_DIR/$_day"
298 +
299 +
    for _target in $TARGETS; do
300 +
        for _fam in $FAMILIES; do
301 +
302 +
            [ "$STOP" -eq 1 ] && break
303 +
304 +
            measure_curl "$_target" "$_fam"
305 +
306 +
            # Wurde die Messung durch ein Abbruchsignal zerrissen, waere das
307 +
            # Ergebnis ein Scheinausfall. Solche Werte gehoeren nicht in die
308 +
            # Auswertung, deshalb wird die Messung ersatzlos verworfen.
309 +
            if [ "$STOP" -eq 1 ]; then
310 +
                log "SKIP $_target IPv$_fam waehrend Abbruch verworfen (nicht gewertet)"
311 +
                break
312 +
            fi
313 +
314 +
            _tmpraw="$RAW_DIR/$_day/.tmp-$_runid-$_target-v$_fam.txt"
315 +
            measure_mtr "$_target" "$_fam" "$MTR_CYCLES" "$_tmpraw"
316 +
317 +
            if [ "$STOP" -eq 1 ]; then
318 +
                rm -f "$_tmpraw"
319 +
                log "SKIP $_target IPv$_fam waehrend Abbruch verworfen (nicht gewertet)"
320 +
                break
321 +
            fi
322 +
323 +
            # Auffaelligkeit bewerten
324 +
            _anomaly=0
325 +
            _reason=""
326 +
327 +
            if [ "$c_ok" -ne 1 ]; then
328 +
                _anomaly=1
329 +
                _reason="curl fehlgeschlagen (exit $c_exit, http $c_code)"
330 +
            elif [ -n "$c_total" ] && [ "$(awk -v a="$c_total" -v b="$TOTAL_WARN" 'BEGIN{print (a>b)?1:0}')" = "1" ]; then
331 +
                _anomaly=1
332 +
                _reason="curl langsam (${c_total} ms)"
333 +
            fi
334 +
335 +
            case "$c_code" in
336 +
                ""|000|5*) _anomaly=1; [ -z "$_reason" ] && _reason="HTTP-Status $c_code" ;;
337 +
            esac
338 +
339 +
            if [ "$m_ok" -eq 1 ]; then
340 +
                if [ "$m_dest_reached" -ne 1 ]; then
341 +
                    _anomaly=1
342 +
                    _reason="${_reason:+$_reason; }Ziel im mtr nicht erreicht"
343 +
                fi
344 +
                if [ "$(awk -v a="$m_loss" -v b="$LOSS_WARN" 'BEGIN{print (a+0>b+0)?1:0}')" = "1" ]; then
345 +
                    _anomaly=1
346 +
                    _reason="${_reason:+$_reason; }Paketverlust ${m_loss}%"
347 +
                fi
348 +
                if [ "$(awk -v a="$m_avg" -v b="$RTT_WARN" 'BEGIN{print (a+0>b+0)?1:0}')" = "1" ]; then
349 +
                    _anomaly=1
350 +
                    _reason="${_reason:+$_reason; }Latenz ${m_avg} ms"
351 +
                fi
352 +
            elif have mtr; then
353 +
                _anomaly=1
354 +
                _reason="${_reason:+$_reason; }mtr ohne Ergebnis"
355 +
            fi
356 +
357 +
            # Rohlog nur bei Auffaelligkeit behalten, dann aber mit mehr Zyklen
358 +
            _rawname=""
359 +
            if [ "$_anomaly" -eq 1 ]; then
360 +
                _rawname="$_day/$_runid-$_target-v$_fam.txt"
361 +
                _rawpath="$RAW_DIR/$_rawname"
362 +
                {
363 +
                    echo "=== $_ts $_target IPv$_fam ==="
364 +
                    echo "Grund: $_reason"
365 +
                    echo "curl: exit=$c_exit http=$c_code ip=$c_ip dns=${c_dns}ms connect=${c_con}ms tls=${c_tls}ms ttfb=${c_ttfb}ms total=${c_total}ms"
366 +
                    echo
367 +
                    echo "--- mtr Kurzlauf ($MTR_CYCLES Zyklen) ---"
368 +
                } >"$_rawpath"
369 +
                [ -f "$_tmpraw" ] && cat "$_tmpraw" >>"$_rawpath"
370 +
371 +
                if have mtr && [ "$MTR_ANOMALY_CYCLES" -gt "$MTR_CYCLES" ]; then
372 +
                    {
373 +
                        echo
374 +
                        echo "--- mtr Nachmessung ($MTR_ANOMALY_CYCLES Zyklen) ---"
375 +
                    } >>"$_rawpath"
376 +
                    _tmpraw2="$_tmpraw.2"
377 +
                    measure_mtr "$_target" "$_fam" "$MTR_ANOMALY_CYCLES" "$_tmpraw2" || true
378 +
                    [ -f "$_tmpraw2" ] && cat "$_tmpraw2" >>"$_rawpath" && rm -f "$_tmpraw2"
379 +
                    if [ "$STOP" -eq 1 ]; then
380 +
                        echo "(Nachmessung durch Abbruchsignal beendet - unvollstaendig)" >>"$_rawpath"
381 +
                    fi
382 +
                fi
383 +
384 +
                [ "$SNAPSHOT_ON_ANOMALY" = "1" ] && snapshot "$_rawpath"
385 +
386 +
                log "ANOMALY $_target IPv$_fam: $_reason -> raw/$_rawname"
387 +
            fi
388 +
            rm -f "$_tmpraw"
389 +
390 +
            printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \
391 +
                "$_ts" "$_epoch" "$_runid" "$_target" "$_fam" "$(csv_escape "$c_ip")" \
392 +
                "$c_ok" "$c_exit" "$c_code" \
393 +
                "$c_dns" "$c_con" "$c_tls" "$c_ttfb" "$c_total" \
394 +
                "$m_ok" "$m_loss" "$m_avg" "$m_worst" "$m_maxhoploss" "$m_hops" \
395 +
                "$m_dest_reached" "$_anomaly" "$(csv_escape "$_reason")" "$_rawname" \
396 +
                >>"$_csv" 2>/dev/null
397 +
398 +
            # Schreibfehler nicht verschlucken - eine stumm verlorene Messung
399 +
            # ist schlimmer als eine laute Fehlermeldung.
400 +
            if [ ! -w "$_csv" ]; then
401 +
                log "FEHLER CSV nicht beschreibbar: $_csv"
402 +
                printf 'netcheck: FEHLER - kann nicht nach %s schreiben.\n' "$_csv" >&9
403 +
            fi
404 +
405 +
            if [ "$VERBOSE" -eq 1 ]; then
406 +
                if [ "$_anomaly" -eq 1 ]; then
407 +
                    printf '  %-24s IPv%s  AUFFAELLIG  %s\n' "$_target" "$_fam" "$_reason"
408 +
                else
409 +
                    printf '  %-24s IPv%s  ok  http=%s ttfb=%sms loss=%s%% avg=%sms hops=%s\n' \
410 +
                        "$_target" "$_fam" "$c_code" "$c_ttfb" "${m_loss:-n/a}" "${m_avg:-n/a}" "${m_hops:-n/a}"
411 +
                fi
412 +
            fi
413 +
        done
414 +
        [ "$STOP" -eq 1 ] && break
415 +
    done
416 +
417 +
    if [ "$VERBOSE" -eq 1 ]; then
418 +
        _rows=$(( $(wc -l <"$_csv" 2>/dev/null || echo 1) - 1 ))
419 +
        printf '  -> %s Zeilen in %s\n' "$_rows" "$_csv"
420 +
    fi
421 +
422 +
    # Alte Rohlogs aufraeumen
423 +
    if [ "$RAW_RETENTION_DAYS" -gt 0 ] && [ -d "$RAW_DIR" ]; then
424 +
        find "$RAW_DIR" -type f -name '*.txt' -mtime "+$RAW_RETENTION_DAYS" -delete 2>/dev/null
425 +
        find "$RAW_DIR" -mindepth 1 -type d -empty -delete 2>/dev/null
426 +
    fi
427 +
}
428 +
429 +
# ---------------------------------------------------------------------------
430 +
# Auswertung
431 +
# ---------------------------------------------------------------------------
432 +
433 +
do_summary() {
434 +
    _month="${1:-}"
435 +
    if [ -n "$_month" ]; then
436 +
        _files="$CSV_DIR/netcheck-$_month.csv"
437 +
    else
438 +
        _files="$CSV_DIR"/netcheck-*.csv
439 +
    fi
440 +
441 +
    # shellcheck disable=SC2086
442 +
    ls $_files >/dev/null 2>&1 || die "keine CSV-Daten in $CSV_DIR gefunden"
443 +
444 +
    # shellcheck disable=SC2086
445 +
    awk -F, '
446 +
        FNR == 1 { next }
447 +
        NF < 20 { next }
448 +
        {
449 +
            ts = $1; target = $4; fam = $5;
450 +
            curl_ok = $7; total = $14;
451 +
            mtr_ok = $15; loss = $16; avg = $17;
452 +
            anomaly = $22; reason = $23;
453 +
454 +
            key = target " IPv" fam;
455 +
            if (!(key in seen)) { seen[key] = 1; order[++n] = key }
456 +
457 +
            runs[key]++;
458 +
            if (curl_ok != 1) fails[key]++;
459 +
            if (anomaly == 1) anos[key]++;
460 +
461 +
            if (curl_ok == 1 && total != "") {
462 +
                sum_total[key] += total; cnt_total[key]++;
463 +
                if (total + 0 > max_total[key] + 0) max_total[key] = total;
464 +
            }
465 +
            if (mtr_ok == 1 && avg != "") {
466 +
                sum_avg[key] += avg; cnt_avg[key]++;
467 +
                if (avg + 0 > max_avg[key] + 0) max_avg[key] = avg;
468 +
                sum_loss[key] += loss; cnt_loss[key]++;
469 +
            }
470 +
471 +
            if (anomaly == 1) {
472 +
                hour = substr(ts, 1, 13);
473 +
                hits[hour]++;
474 +
                day = substr(ts, 1, 10);
475 +
                dayhits[day]++;
476 +
                if (!(reason in rcount)) rorder[++rn] = reason;
477 +
                rcount[reason]++;
478 +
                if (first[key] == "") first[key] = ts;
479 +
                last[key] = ts;
480 +
            }
481 +
            if (window_first == "") window_first = ts;
482 +
            window_last = ts;
483 +
            total_runs++;
484 +
        }
485 +
        END {
486 +
            if (total_runs == 0) { print "Keine Datenzeilen gefunden."; exit }
487 +
            printf "Messzeitraum : %s bis %s\n", window_first, window_last;
488 +
            printf "Messungen    : %d\n\n", total_runs;
489 +
490 +
            printf "%-28s %7s %7s %8s %10s %10s %9s %9s\n", \
491 +
                "Ziel", "Laeufe", "Fehler", "Auffaell", "Ø total ms", "max total", "Ø loss %", "Ø rtt ms";
492 +
            for (i = 1; i <= n; i++) {
493 +
                k = order[i];
494 +
                at = (cnt_total[k] ? sum_total[k] / cnt_total[k] : 0);
495 +
                al = (cnt_loss[k]  ? sum_loss[k]  / cnt_loss[k]  : 0);
496 +
                ar = (cnt_avg[k]   ? sum_avg[k]   / cnt_avg[k]   : 0);
497 +
                printf "%-28s %7d %7d %8d %10.1f %10.1f %9.2f %9.1f\n", \
498 +
                    k, runs[k], fails[k]+0, anos[k]+0, at, max_total[k]+0, al, ar;
499 +
            }
500 +
501 +
            if (rn > 0) {
502 +
                printf "\nHaeufigste Ursachen\n";
503 +
                for (i = 1; i <= rn; i++)
504 +
                    printf "  %5d x  %s\n", rcount[rorder[i]], rorder[i];
505 +
506 +
                printf "\nAuffaellige Laeufe pro Tag\n";
507 +
                for (d in dayhits) daylist[++dn] = d;
508 +
                for (i = 1; i < dn; i++) for (j = i+1; j <= dn; j++)
509 +
                    if (daylist[i] > daylist[j]) { t = daylist[i]; daylist[i] = daylist[j]; daylist[j] = t }
510 +
                for (i = 1; i <= dn; i++) {
511 +
                    bar = ""; c = dayhits[daylist[i]];
512 +
                    for (b = 0; b < c && b < 60; b++) bar = bar "#";
513 +
                    printf "  %s  %4d  %s\n", daylist[i], c, bar;
514 +
                }
515 +
516 +
                printf "\nAuffaellige Laeufe pro Stunde (Tagesverteilung)\n";
517 +
                for (h in hits) { hh = substr(h, 12, 2); hourly[hh] += hits[h] }
518 +
                for (i = 0; i < 24; i++) {
519 +
                    hh = sprintf("%02d", i);
520 +
                    c = hourly[hh] + 0;
521 +
                    bar = ""; for (b = 0; b < c && b < 60; b++) bar = bar "#";
522 +
                    printf "  %s Uhr  %4d  %s\n", hh, c, bar;
523 +
                }
524 +
            } else {
525 +
                printf "\nKeine Auffaelligkeiten im Zeitraum.\n";
526 +
            }
527 +
        }' $_files
528 +
}
529 +
530 +
# ---------------------------------------------------------------------------
531 +
# Selbsttest
532 +
# ---------------------------------------------------------------------------
533 +
534 +
do_selftest() {
535 +
    echo "netcheck Selbsttest"
536 +
    echo "  System        : $(uname -s) $(uname -r)"
537 +
    echo "  Konfiguration : $([ -f "$CONF" ] && echo "$CONF" || echo "(keine, Defaults aktiv)")"
538 +
    echo "  Ziele         : $TARGETS"
539 +
    echo "  Familien      : IPv$(echo "$FAMILIES" | sed 's/ /, IPv/')"
540 +
    echo "  Datenablage   : $OUTDIR"
541 +
    echo
542 +
543 +
    _fail=0
544 +
    for _t in curl awk date find; do
545 +
        if have "$_t"; then
546 +
            echo "  ok      $_t ($(command -v "$_t"))"
547 +
        else
548 +
            echo "  FEHLT   $_t"
549 +
            _fail=1
550 +
        fi
551 +
    done
552 +
553 +
    if have mtr; then
554 +
        echo "  ok      mtr ($(command -v mtr))"
555 +
        if [ "$(id -u)" -eq 0 ]; then
556 +
            echo "  ok      laeuft als root, mtr kann Raw Sockets oeffnen"
557 +
        elif sudo -n true 2>/dev/null; then
558 +
            echo "  ok      passwortloses sudo verfuegbar, mtr wird darueber gestartet"
559 +
        else
560 +
            echo "  WARNUNG weder root noch passwortloses sudo - mtr wird scheitern."
561 +
            echo "          Entweder per root/sudo starten oder eine sudoers-Regel anlegen"
562 +
            echo "          (siehe README, Abschnitt Rechte)."
563 +
            _fail=1
564 +
        fi
565 +
    else
566 +
        echo "  FEHLT   mtr - es werden nur curl-Messungen aufgezeichnet."
567 +
        echo "          macOS: brew install mtr | Debian/Ubuntu: apt install mtr-tiny"
568 +
    fi
569 +
570 +
    echo
571 +
    for _fam in $FAMILIES; do
572 +
        for _t in $TARGETS; do
573 +
            if curl "-$_fam" -sS -I --connect-timeout 5 --max-time 10 \
574 +
                -o /dev/null "https://$_t$CURL_PATH" 2>/dev/null; then
575 +
                echo "  ok      IPv$_fam Testabruf $_t erfolgreich"
576 +
            else
577 +
                echo "  WARNUNG IPv$_fam Testabruf $_t fehlgeschlagen (genau das wuerde das Monitoring aufzeichnen)"
578 +
            fi
579 +
        done
580 +
    done
581 +
582 +
    echo
583 +
    if [ "$_fail" -eq 0 ]; then
584 +
        echo "Selbsttest ohne Blocker abgeschlossen."
585 +
    else
586 +
        echo "Selbsttest mit Problemen abgeschlossen - siehe Meldungen oben."
587 +
    fi
588 +
    return "$_fail"
589 +
}
590 +
591 +
# ---------------------------------------------------------------------------
592 +
# Signalbehandlung und Aufraeumen
593 +
# ---------------------------------------------------------------------------
594 +
595 +
# Ctrl+C bzw. SIGTERM beenden das Script nicht mitten in einer Messung, weil
596 +
# eine abgebrochene Messung als Ausfall in der CSV landen und die Auswertung
597 +
# verfaelschen wuerde. Stattdessen wird ein Stopp-Wunsch vermerkt, die
598 +
# angefangene Messung verworfen und danach sauber beendet.
599 +
# Ein zweites Signal erzwingt den sofortigen Abbruch.
600 +
on_signal() {
601 +
    SIGCOUNT=$(( SIGCOUNT + 1 ))
602 +
    STOP=1
603 +
604 +
    # Laufende Messprozesse beenden, damit der Abbruch sofort greift
605 +
    [ -n "$SLEEP_PID" ] && kill -TERM "$SLEEP_PID" 2>/dev/null
606 +
    [ -n "$CHILD_PID" ] && kill -TERM "$CHILD_PID" 2>/dev/null
607 +
608 +
    if [ "$SIGCOUNT" -ge 2 ]; then
609 +
        printf '\nnetcheck: Abbruch erzwungen.\n' >&9
610 +
        log "STOP zweites Signal - sofortiger Abbruch"
611 +
        cleanup
612 +
        exit 130
613 +
    fi
614 +
615 +
    printf '\nnetcheck: Stopp angefordert. Laufende Messung wird verworfen (nicht in die CSV geschrieben).\n' >&9
616 +
    printf 'netcheck: nochmal Ctrl+C erzwingt den sofortigen Abbruch.\n' >&9
617 +
    log "STOP Signal empfangen - laufende Messung wird verworfen"
618 +
}
619 +
620 +
cleanup() {
621 +
    [ -n "$CHILD_PID" ] && kill -TERM "$CHILD_PID" 2>/dev/null
622 +
    [ -n "$SLEEP_PID" ] && kill -TERM "$SLEEP_PID" 2>/dev/null
623 +
    # angefangene Rohlogs der abgebrochenen Messung entfernen
624 +
    rm -f "$RAW_DIR"/*/.tmp-* 2>/dev/null
625 +
    [ "$LOCK_HELD" -eq 1 ] && rm -rf "$LOCKDIR"
626 +
    return 0
627 +
}
628 +
629 +
install_traps() {
630 +
    trap 'on_signal' INT TERM
631 +
    trap 'cleanup' EXIT
632 +
}
633 +
634 +
# ---------------------------------------------------------------------------
635 +
# Sperre gegen ueberlappende Laeufe
636 +
# ---------------------------------------------------------------------------
637 +
638 +
acquire_lock() {
639 +
    if mkdir "$LOCKDIR" 2>/dev/null; then
640 +
        echo $$ >"$LOCKDIR/pid"
641 +
        LOCK_HELD=1
642 +
        install_traps
643 +
        return 0
644 +
    fi
645 +
    _pid="$(cat "$LOCKDIR/pid" 2>/dev/null)"
646 +
    if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then
647 +
        log "SKIP vorheriger Lauf (PID $_pid) laeuft noch"
648 +
        return 1
649 +
    fi
650 +
    log "WARN verwaiste Sperre von PID ${_pid:-unbekannt} entfernt"
651 +
    rm -rf "$LOCKDIR"
652 +
    mkdir "$LOCKDIR" 2>/dev/null || return 1
653 +
    echo $$ >"$LOCKDIR/pid"
654 +
    LOCK_HELD=1
655 +
    install_traps
656 +
    return 0
657 +
}
658 +
659 +
# ---------------------------------------------------------------------------
660 +
# Einstiegspunkt
661 +
# ---------------------------------------------------------------------------
662 +
663 +
mkdir -p "$CSV_DIR" "$RAW_DIR" || die "kann $OUTDIR nicht anlegen"
664 +
665 +
case "${1:-run}" in
666 +
    once)
667 +
        VERBOSE=1
668 +
        acquire_lock || exit 0
669 +
        echo "netcheck $(date '+%Y-%m-%dT%H:%M:%S%z')"
670 +
        echo "CSV:  $CSV_DIR/netcheck-$(date '+%Y-%m').csv"
671 +
        do_run
672 +
        echo "CSV: $CSV_DIR/netcheck-$(date '+%Y-%m').csv"
673 +
        ;;
674 +
    run)
675 +
        acquire_lock || exit 0
676 +
        do_run
677 +
        ;;
678 +
    daemon)
679 +
        VERBOSE=1
680 +
        acquire_lock || die "es laeuft bereits eine Instanz"
681 +
        log "START daemon, Intervall ${INTERVAL}s, Ziele: $TARGETS"
682 +
        echo "netcheck laeuft. Beenden mit Ctrl+C oder: $0 stop"
683 +
        echo "CSV:  $CSV_DIR/netcheck-$(date '+%Y-%m').csv"
684 +
        echo "Roh:  $RAW_DIR  (nur bei Auffaelligkeiten)"
685 +
        while [ "$STOP" -eq 0 ]; do
686 +
            _start="$(date '+%s')"
687 +
            do_run
688 +
            [ "$STOP" -eq 1 ] && break
689 +
690 +
            _elapsed=$(( $(date '+%s') - _start ))
691 +
            _sleep=$(( INTERVAL - _elapsed ))
692 +
            [ "$_sleep" -lt 5 ] && _sleep=5
693 +
694 +
            # Schlafen im Hintergrund, damit ein Signal sofort greift und
695 +
            # nicht bis zum Ende des Intervalls warten muss.
696 +
            sleep "$_sleep" &
697 +
            SLEEP_PID=$!
698 +
            wait "$SLEEP_PID" 2>/dev/null
699 +
            SLEEP_PID=""
700 +
        done
701 +
        log "STOP daemon beendet"
702 +
        echo "netcheck beendet."
703 +
        ;;
704 +
    stop)
705 +
        _pid="$(cat "$LOCKDIR/pid" 2>/dev/null)"
706 +
        if [ -z "$_pid" ]; then
707 +
            echo "netcheck: keine laufende Instanz gefunden (keine Sperre in $LOCKDIR)."
708 +
            exit 0
709 +
        fi
710 +
        if ! kill -0 "$_pid" 2>/dev/null; then
711 +
            echo "netcheck: verwaiste Sperre von PID $_pid entfernt."
712 +
            rm -rf "$LOCKDIR"
713 +
            exit 0
714 +
        fi
715 +
        echo "netcheck: sende Stoppsignal an PID $_pid ..."
716 +
        kill -TERM "$_pid" 2>/dev/null
717 +
        _n=0
718 +
        while [ "$_n" -lt 60 ] && kill -0 "$_pid" 2>/dev/null; do
719 +
            sleep 1
720 +
            _n=$(( _n + 1 ))
721 +
        done
722 +
        if kill -0 "$_pid" 2>/dev/null; then
723 +
            echo "netcheck: reagiert nicht, erzwinge Abbruch."
724 +
            kill -KILL "$_pid" 2>/dev/null
725 +
            sleep 1
726 +
            rm -rf "$LOCKDIR"
727 +
        else
728 +
            echo "netcheck: sauber beendet."
729 +
        fi
730 +
        ;;
731 +
    summary)
732 +
        do_summary "${2:-}"
733 +
        ;;
734 +
    selftest)
735 +
        do_selftest
736 +
        ;;
737 +
    -h|--help|help)
738 +
        sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
739 +
        ;;
740 +
    *)
741 +
        die "unbekannter Befehl '${1}'. Bekannt: once, run, daemon, stop, summary, selftest"
742 +
        ;;
743 +
esac
\ No newline at end of file
hinzugefügt netcheck.timer
+12 −0 Datei ansehen
@@ -0,0 +1,12 @@
1 +
[Unit]
2 +
Description=netcheck alle 5 Minuten
3 +
4 +
[Timer]
5 +
OnBootSec=2min
6 +
OnUnitActiveSec=5min
7 +
AccuracySec=10s
8 +
Persistent=true
9 +
Unit=netcheck.service
10 +
11 +
[Install]
12 +
WantedBy=timers.target