Compare commits

..
57 Commits
Author SHA1 Message Date
kalina 6b169c77e6 ArchiveBox-Telemetrie: Exporteur + Telegraf-Container fuer Knoten ohne sudo
archibald hat kein sudo und kein linger, aber Docker-Gruppe. Muster wie
bei der llama-swap-Telemetrie auf dem ROG: ein Nutzer-Cron fuehrt den
Exporteur aus, Telegraf laeuft als Container daneben und pusht per
Remote Write zum Collector im Mesh.

- scripts/archivebox-telemetrie.py: liest die ArchiveBox-Logs und die
  SQLite-DB IM Container (Host-User mm darf index.sqlite3 nicht oeffnen,
  Container-User 911 schon) und schreibt eine Prometheus-Exposition.
  Kein node-Label -- das setzt Telegraf per global_tags.
- scripts/install-archivebox-telemetrie.sh: installiert Exporteur,
  Cron-Eintrag (jede Minute) und den Container, mit --dry-run.
- stacks/telegraf-container/: telegraf 1.39, network_mode host,
  inputs.file (data_format prometheus), outputs.prometheus_remote_write.
2026-10-10 23:26:19 +02:00
mm 235cbc68dc llama-swap-Telemetrie als Nutzer-Instanz (Knoten ohne sudo)
Warum: llama-swap laeuft als Nutzer-Unit. Der System-Telegraf liest das
Systemjournal mit priority>=warning und liest damit keine einzige
llama-swap-INFO-Zeile - Loki hatte fuer den ROG null Zeilen. Ausserdem kann
die System-Instanz auf einem Knoten ohne sudo nicht erweitert werden.

- scripts/llamaswap-telemetrie.py: Token/Anfragen je Modell aus llama-swaps
  Statistik-API plus llama.cpp /metrics je laufendem Profil (Ports sind
  dynamisch, deshalb kein statischer Scrape)
- scripts/llamaswap-logs.py: Nutzer-Journal (llama-swap, kalina-backends)
  nach Loki, gleiche Zeilenform wie journal-tail.sh
- telegraf/llamaswap-user.conf + .service: Zugangsdaten per
  EnvironmentFile=/etc/default/telegraf, keine Token-Kopie
- scripts/install-llamaswap-user.sh: idempotente Installation mit --dry-run
  und Konfigurationspruefung
2026-10-10 22:25:18 +02:00
Kalina 59ab6a9a22 fix(docker): auch das Feld health_status verwerfen (String-Feld)
Zweites String-Feld derselben Klasse wie container_id: inputs.docker liefert
health_status="healthy". Telegraf macht daraus docker_container_health_health_status
und Prometheus antwortet 'bad sample value "healthy"' -> der ganze Flush faellt aus,
auch die Container-Statistiken, die damit nichts zu tun haben.

GEMESSEN auf kalina nach dem ersten Fix: genau EINE 'series were dropped'-Zeile
mit 862 Serien im haengenden Batch, last recorded error health_status. Nach dem
Zusatz: 0 E!, 0 dropped, docker_container_* wieder da (kalina 501, imac 1126
Serien).

Feldliste diesmal empirisch erzeugt statt geraten: telegraf --test des
docker-Inputs ohne Prozessoren, alle Felder mit Anfuehrungszeichen gesammelt --
es sind genau ZWEI (container_id, health_status).

VERSION 1.9.3 -> 1.9.4.
2026-10-09 15:43:55 +02:00
Kalina 098f4cf7af fix(output): non_retryable_statuscodes [400,409,413,422] gegen Endlosschleife
Ein permanenter 400 (vergifteter Batch) blieb am FIFO-Kopf und wurde endlos
wiederholt; die gepufferten Samples alterten dabei bis ueber die Akzeptanz-
grenze des Empfaengers (head_min_time ~2x Blockdauer) und waren danach
unzustellbar — der Knoten verstummte dauerhaft.

GEMESSEN 09.10.2026: 9 von 15 Nodes stumm, Sample-Versatz 185-1071 min
(konstant ueber 72 h), 1680 Ablehnungen/h, danach 'Metric buffer overflow'
= endgueltiger Verlust. Mit der Option wird der Batch verworfen statt
wiederholt ('Received non-retryable status 400. Metrics are lost.') und der
Knoten erholt sich beim naechsten Flush selbst. 5xx/408/429 bleiben retrybar.

Zweiter Fix, gleiche Ursache: der vorhandene processors.override traf nur
'docker_log' und liess docker_container_blkio_container_id (String) durch —
der 400 warf die ganze docker_container_*-Familie weg. Neuer Block
namepass=['docker','docker_container*'] mit fieldexclude der *_id-Felder.

VERSION 1.9.2 -> 1.9.3 (loest den Auto-Updater aus).
2026-10-09 15:34:26 +02:00
Kalina 63bc054371 fix: Output-Timeouts 10s->60s; docker-events-Watcher fuer Docker-Nodes
Die 10-s-Timeouts waren kuerzer als die gemessene Zustellzeit (11-19 s durch
den NetBird-Reverse-Proxy auf x05) -> jeder Batch brach ab. Gemessen: 702
abgelehnte Requests in 20 min, Metriken fehlten still.

Neu: scripts/docker-events-loki.py + Wrapper + systemd-Unit. Docker-Events
(Neustart/OOM/Stop) gehen nach Loki, Spool-Datei + Cursor statt RAM-Puffer.
2026-09-20 09:17:05 +02:00
Kalina 1fe25163d5 feat(agent): metric_buffer_limit 20000 -> 200000 (V1.9.2)
GEMESSEN: x22 erzeugt 13,3 Log-Zeilen/s. Die alte Reserve von 20.000
reichte damit nur ~25 min — bei einem Ausfall von einer Stunde waeren
Logs verloren gegangen, ohne dass es auffiele.

200.000 = ~4 h Reserve, belegt nur waehrend eines Ausfalls (Telegraf
puffert nichts, solange der Push klappt). Ein Ueberlauf wird geloggt
('[outputs.loki] Metric buffer overflow'), also nicht still.

Kein Dienstneustart noetig ausser der regulaere Konfigurationslauf.
2026-09-14 20:07:34 +02:00
Kalina d65289e65f fix(rawlogs): .gz-Member im Tarball zweimal entpacken
Die .gz-Dateien INNERHALB eines Backfill-Tarballs sind nochmals
gzip-komprimiert; tarfile entpackt nur die aeussere Ebene. Wer den
Member-Inhalt direkt dekodiert, liest gzip-Magic als Text und verliert
jede Zeile dieser Dateien — real 68.975 von 404.825 (17 %).

- .gz-Member werden ein zweites Mal entpackt, die Zahl wird gemeldet
- --limit-lines greift jetzt auch im Tarball-Zweig
- Beispiele unlesbarer Zeilen zeigen den Dateinamen
2026-09-14 19:40:04 +02:00
Kalina a40836a592 fix(rawlogs): auf /data/tmp ausweichen, /tmp ist tmpfs (15 GB RAM)
Am Ziel gemessen: /tmp ist ein tmpfs mit 15 GB. Eine 10-GB-Rohdatei plus
Sortier-Temp liegt damit im Arbeitsspeicher und bringt Loki/den Rechner
in Bedraengnis. Der Importer prueft jetzt vorher den freien Platz und
bricht frueh ab; die Uebergabe geht nach /data/tmp (863 GB frei).
2026-09-14 17:49:05 +02:00
Kalina b8246472f5 fix(rawlogs): Uebergabedateien lesbar in /tmp/rawlog-upload legen
Der Collector laeuft als root (Logs unter /home/users mit 0700), seine
Ausgabe ist damit 0600 fuer root. scp als normaler Nutzer kam nicht an
die Datei: 'stat ... Keine Berechtigung'. Jetzt werden Kopien mit 644
in /tmp/rawlog-upload/ abgelegt — von beiden Seiten erreichbar.
2026-09-14 17:32:08 +02:00
Kalina f8c5f2eb3c feat(rawlogs): Rohlogs fuer den Loki-Import sammeln
Der Prometheus-Archiv-Import verdichtet auf Stunde/Domain/Status und
verwirft Referrer, User-Agent und Client-IP. Fragen nach der Herkunft
liessen sich damit nicht rueckwirkend beantworten.

- raw-log-collect.py: liest access+error (inkl. .gz, rotierte Dateien),
  schreibt ts_ns/labels/rohzeile und meldet Zeilen ohne Zeitstempel
  statt sie still zu verwerfen. Error-Logs ohne Zeitzone (Apache-
  Standard) werden korrekt nach UTC verschoben.
- raw-log-backfill.sh: Wrapper mit Messmodus; sammelt auf dem Host,
  importiert selbst NICHTS.
2026-09-14 17:31:14 +02:00
mm 33534811cb fix(backfill): Abdeckung pruefen — "Zeitraum min..max" war irrefuehrend
Der x22-Backfill meldete "Zeitraum: 03.12.2024 .. 14.09.2026" und liest sich
damit wie zwei Jahre Historie. Tatsaechlich enthielt die Quelle EINEN einzelnen
Punkt vom 03.12.2024 (eine alte Logzeile) und sonst 30 Tage:

  belegte Stunden: 721 von 15.601 im Zeitraum  (4.6 % Abdeckung)
  groesste Luecke: 620 Tage  03.12.2024 21:00 -> 15.08.2026 22:00

min..max allein sagt nichts ueber die Substanz. Die Selbstpruefung nennt jetzt
zusaetzlich die Zahl der belegten Stunden gegen die Spanne, die prozentuale
Abdeckung und — bei unter 50 % — die drei groessten Luecken mit Datum, plus den
Hinweis auf fehlende/rotierte Logs als typische Ursache.

Verifiziert gegen drei Faelle: (A) 1 alter Punkt + 30 Tage -> 4.6 %, Luecke
benannt; (B) 15 Tage durchgehend -> 100 %, kein Alarm; (C) 29 Tage mit 20-h-
Luecke -> 97.3 %, kein Alarm (echte kleine Luecken sind normal).
2026-09-14 15:53:44 +02:00
mm 39e86adfba feat(backfill): Skript fuer alle KeyHelp-Hosts generalisiert
x22-log-backfill.sh heisst jetzt keyhelp-log-backfill.sh und leitet NODE aus
hostname -f ab, wenn es nicht gesetzt ist. Damit ist derselbe Ablauf fuer x14
nutzbar, ohne x22 im instance-Label zu tragen.

Der alte Name bleibt als Alias erhalten (laedt das generische Skript, NODE
vorbelegt auf x22) — bereits verteilte Aufrufe laufen weiter.

Verifiziert im Container mit x14-Nachbau: instance="x14.idx.wf-keyhelp",
Messmodus read-only, Selbstpruefung (Einheit/Zukunft/Domains) greift.
2026-09-14 15:29:11 +02:00
mm c18263e4ed fix(backfill): Survey-Aufruf ohne --node, Hinweis auf Zeilenpufferung
Der Survey kennt kein argparse — '--node' wurde still ignoriert (harmlos, aber
irrefuehrend). Argument entfernt und die Puffer-Falle im Aufruf dokumentiert.
2026-09-14 12:10:03 +02:00
mm df2ee94613 fix(survey): Fortschritt sichtbar — der leere bestand.txt war kein Fehler, sondern ein laufender Scan
BEFUND (x22, gemessen und reproduziert):
Der Aufruf war 'python3 apache-log-survey.py 2>&1 | tee bestand.txt'. Das Skript
gab ALLES erst nach der Scan-Schleife aus (alle print() standen dahinter), und
Python puffert stdout in eine PIPE blockweise (4-8 KB). Die Gesamtausgabe ist
aber nur ~1 KB -> sie landete erst beim Prozessende in der Datei.

Messung mit 2 Mio Zeilen (140 MB):
  mit tee:     nach 5 s = 0 Bytes, fertig nach 41 s -> dann 1028 Bytes
  ohne Pipe:   nach 1 s = 0 Bytes
  python3 -u:  nach 1 s = 0 Bytes
=> Die Datei war leer, weil der Scan LIEF. Auf einem KeyHelp-Host mit tausenden
   Logdateien dauert das Minuten und liest sich wie ein Haenger oder Absturz.

FIX (drei Massnahmen, damit das nie wieder passiert):
1. sys.stdout.reconfigure(line_buffering=True) — jede Zeile geht sofort raus,
   auch durch eine Pipe.
2. Startbanner VOR dem Scan: Anzahl und Gesamtgroesse der Dateien.
3. Fortschrittszeile pro Datei (Nummer/Gesamtzahl, Zeilen, Groesse, Pfad) plus
   eine Zeile fuer nicht lesbare Dateien.

Verifiziert mit 1,4 Mio Zeilen durch eine Pipe: nach 2 s steht das Banner, drei
Fortschrittszeilen erscheinen waehrend des Scans. Gegenprobe mit dem alten Code:
nach 3 s 16 Bytes, alles erst am Ende.
2026-09-14 12:08:48 +02:00
mm 194fa98f97 fix(archive): Error-Logs ohne Zeitzone wurden STILL verworfen + Backfill-Skript
BEFUND (echter Datenverlust, gemessen gegen KeyHelp-Formate):
KeyHelp/Debian schreiben error.log im Access-Format, aber OHNE Zeitzone:
    [01/Sep/2026:10:00:05] [core:error] [pid 9] AH00124: ...
TS_FORMATS kannte nur "%d/%b/%Y:%H:%M:%S %z" (MIT Zone). parse_ts() gab None,
hour wurde "unknown", und der Schreiber ueberspringt unknown-Zeilen:
apache_archive_errors_total fehlte KOMPLETT — kein Fehler, kein Log, Panel leer.
Genau die stille Fehlerklasse, die nicht passieren darf.

FIX:
- Format "%d/%b/%Y:%H:%M:%S" (ohne Zone) ergaenzt.
- Unparsbare Zeitstempel werden GEZAEHLT und laut gemeldet (Domain + Beispiel +
  Hinweis auf TS_FORMATS) statt still zu verschwinden.
- Neu: scripts/x22-log-backfill.sh — messen (read-only) und parsen in einem
  Aufruf, mit Selbstpruefung (Einheiten, Zukunft, Domain-Anzahl) VOR der
  Uebergabe und dem sicheren Import-Befehl am Ende.

Verifiziert im Container gegen echte KeyHelp-Formate inkl. rotierter .1 und
.gz-Datei: Messmodus aendert nichts (MD5-Vergleich), Backfill erzeugt alle
fuenf Metriken inkl. errors_total{level=error} und {level=warn}.
2026-09-14 11:49:05 +02:00
mm 7d3ce17c53 fix(archive): Anleitung zeigt auf das Archiv, nie auf die Produktions-TSDB; x22-Wrapper mit --file 2026-09-14 11:43:19 +02:00
mm 72752e523e chore(installer): Update-Only meldet gesetzte ACLs ehrlich + VERSION 1.9.1 2026-09-14 11:29:19 +02:00
mm e2c75a88df fix(acl): _touched initialisieren — unter set -u brach die Rechtevergabe kommentarlos ab 2026-09-14 11:22:06 +02:00
mm 6f82bad6f9 fix(acl): Glob-Wurzeln brauchen r-x — Ursache der stummen Datenluecke auf x14
x14 sendete 105 Metriken, aber KEINE apache-Metriken und keine Logs. Der
Installer meldete Erfolg. Telegraf selbst nannte den Grund:

  W! [inputs.tail] Directory "/home/users" is not readable but is followed
     by wildcards, make sure you set read permissions or globbing will not work!

Drei Ursachen, alle behoben:

1. `setfacl -m u:telegraf:x` auf der Glob-Wurzel ist FALSCH. Eine Wildcard wird
   per readdir expandiert — dafuer braucht das Verzeichnis `r-x`, nicht nur `x`.
   Neue Logik: Muster werden mit `set -f` gelesen, dann je Treffer die Datei mit
   `r` und JEDE Ebene zwischen Wurzel und Datei mit `r-x` belegt.
   Gemessen: alte ACL -> 1 Warnung, 1 Punkt (nur /var/log/apache2);
   neue ACL -> 0 Warnungen, 4 Punkte (alle 3 Dateien inkl. User-Homes).

2. Der Gegentest war blind: er las jede gefundene Datei per `head -c 1` als
   telegraf. Das konnte nicht fehlschlagen, weil die Datei vom ROOT-Shell
   expandiert wurde — die Glob-Wurzel blieb unlesbar, Telegraf bekam 0 Treffer,
   die Meldung sagte trotzdem "alle N Logdateien lesen". Jetzt wird die
   GLOB-AUFLOESUNG gemessen: dasselbe Muster im Dienstkontext expandieren und die
   Trefferzahl vergleichen. 0 von 0 zaehlt als Fehler, nicht als Erfolg.

3. `for p in $_pfadquelle` ohne `set -f` expandiert die Muster im Root-Shell.
   _pattern war danach ein konkreter Dateiname, der Wildcard-Zweig lief nie, und
   die ACL landete nur auf den Dateien. Real passiert: "ACL auf 3 Datei(en)",
   "kein Glob-Muster gefunden", 0 Treffer im Dienst.

Verifiziert mit echtem Telegraf 1.40.0 und REALEN KeyHelp-Rechten
(/home/users/<user> 0700, logs 0750, Dateien 0640).
2026-09-14 11:21:00 +02:00
mm 3f9ac51c45 chore: VERSION 1.8.0 (envsubst-Fix) 2026-09-14 10:44:06 +02:00
mm ea5b796012 fix(installer): envsubst-Redirection wiederhergestellt — DAS war der x14-Haenger
Der Trace auf x14 endete exakt hier:
  + envsubst ${NODE_NAME} ... ${APACHE_ERROR_FILES}
  root@x14:/home/mm#

Ohne "< \$2.tmp" liest envsubst STDIN. Bei sh install.sh vom Terminal ist stdin
die Konsole -> der Installer wartet auf Eingabe, nach der letzten Meldung
"Schreibe Konfiguration ...". Genau das Bild: Stillstand, ^C, kein Fehler.
Bei curl | sh waere es schlimmer: envsubst wuerde den Rest des Skripts fressen.

Ein frueherer Patch von mir hat die Redirection verschluckt; alle Laeufe mit
vorhandener telegraf.conf (x22) liefen trotzdem, weil dort nur der Fallback
subst_vars() aktiv war. Erst der Erstinstall (x14) traf envsubst.

Verifiziert: vorher Haenger bei envsubst, jetzt vollstaendiger Durchlauf mit
CONFIG_VERSION-Marker und allen Confs.
2026-09-14 10:42:21 +02:00
mm 78f5b9ab5e chore: VERSION 1.7.0 2026-09-14 10:36:40 +02:00
mm 6a881ce674 fix(acl): Durchgangsrecht auf Elternverzeichnisse + Gegentest prueft ALLE Pfade
Zwei reale Fehler, beide still:

1. setfacl -R lief nur ab dem logs/-Verzeichnis. /home/users/<user> ist auf
   Panel-Hostern chmod 700 — ohne Durchgangsrecht (x) auf den ELTERNTEIL kommt
   telegraf nicht bis zur Datei. Die Meldung sagte trotzdem "ACL gesetzt".
   Fix: x auf allen Elternverzeichnissen bis zur Wurzel + Default-ACL, damit
   neu angelegte Domain-Ordner erben.

2. Der Gegentest brach beim ERSTEN Treffer ab. Das war
   /var/log/apache2/keyhelp/access.log (root:adm, immer lesbar) -> Meldung
   "Gegentest OK", waehrend ALLE Domain-Logs in den User-Homes unlesbar
   blieben. Fix: jeden Logpfad pruefen und die Anzahl der Fehlschlaege melden.

Verifiziert in Debian-12 mit KeyHelp-aehnlichen Rechten (Home 700, Logs 640).
2026-09-14 10:36:04 +02:00
mm 360f91f8a1 chore: VERSION 1.6.0 2026-09-14 10:33:44 +02:00
mm 60727fe666 fix(installer): Fortschritt vor der Konfigurations-Phase melden (VERSION 1.6.0) 2026-09-14 10:33:43 +02:00
mm c7ab78e9dd fix(acl): nur Log-Verzeichnisse, Timeout pro Wurzel
setfacl -R lief ueber die GANZE Panel-Wurzel (/home/users) — dort liegen auch
Mails, Webinhalte und Backups. Das Recht auf die Logs waere damit teuer und
wirkungslos zugleich. Jetzt werden die logs/-Verzeichnisse eingesammelt
(find -maxdepth 3), und jeder setfacl-Lauf hat ein Timeout von 120 s, damit
ein langsamer Lauf nicht wie ein Haenger aussieht.
2026-09-14 10:33:32 +02:00
mm 7ec5d44517 docs: Timeout-Tabelle + ACL-Phase dauert bis 3 Minuten 2026-09-14 10:29:49 +02:00
mm f5b98d2807 fix(installer): Haenger nach "bereits installiert" behoben (VERSION 1.5.0)
x14 blieb nach der letzten Erfolgsmeldung stehen (Abbruch per ^C, kein Fehler).
Ursache: grant_apache_log_access() ruft apt-get install acl auf — OHNE
apt-get update davor und OHNE Timeout. Bei veralteten Paketlisten kann apt in
eine Rueckfrage laufen und unbegrenzt warten. Direkt danach haette der naechste
fetch gehangen: fetch() hatte ueberhaupt kein curl-Timeout.

Fix:
- apt-get update vor dem acl-Install, timeout 180 auf allen Paketmanager-Aufrufen
- fetch() mit -m 30 + 3 Versuchen statt unbegrenztem Warten
- alle weiteren curl-Aufrufe (keyring, Binary, Re-Exec) mit -m/--connect-timeout
- Fortschrittsmeldung vor der ACL-Phase: ein Haenger sah wie ein Absturz aus
2026-09-14 10:29:12 +02:00
mm d11849b6d9 docs: sicheren Installationsweg nach vorne (erst laden, dann ausfuehren) 2026-09-14 10:23:01 +02:00
mm 906da69481 fix(installer): Re-Exec nur bei echter Pipe ([ -p /dev/stdin ])
Bei `sh install.sh` oder `< eingabe` ist stdin eine Datei — dort kann kein
Unterbefehl das Skript wegfressen. Der Re-Exec war dort unnoetig und kostete
einen Download. Nur `curl … | sh` (echte Pipe) braucht den Neustart.
2026-09-14 10:21:57 +02:00
mm 06ad67f8f5 fix(installer): Re-Exec auch ohne curl (wget/busybox/lokale Quelle)
Auf einem frischen System fehlt curl haeufig — genau dort, wo apt laeuft und
der Schutz am noetigsten ist. Der Re-Exec scheiterte deshalb still und der
Installer lief ohne Schutz weiter.

Jetzt: curl -> wget -> busybox wget -> lokale Quelle (NETBIRD_TG_SOURCE).
Ist keiner verfuegbar, wird das ausdruecklich gemeldet statt still uebergangen.
2026-09-14 10:19:44 +02:00
mm 9e6593a6f6 fix(installer): Re-Exec sichtbar melden (Selbstschutz-Diagnose) 2026-09-14 10:17:36 +02:00
mm 1eb7976ec8 fix(installer): Selbstschutz gegen stdin-Frass bei curl|sh (VERSION 1.4.0)
x14 brach mitten in der Installation ab: der Installer gab seinen eigenen
Quelltext aus ("; then", Kommentarzeilen) und blieb stehen.

Ursache: Bei `curl ... | sh` liest die Shell das Skript aus STDIN. apt startet
auf Ubuntu `needrestart` (Meldung "Scanning processes... / Scanning linux
images..."), und das liest stdin — damit war der Rest des Skripts weg. Die
Shell setzte mitten im Quelltext fort. Bei x22 trat es nicht auf, weil Telegraf
dort schon installiert war (kein apt-Lauf).

Fix, drei Schutzschichten:
1. Re-Exec: laeuft das Skript ueber eine Pipe, laedt es sich frisch von der
   Quelle und startet sich neu — mit stdin auf /dev/null. NICHT per cat von
   stdin kopieren: die Shell puffert bereits, die Kopie waere unvollstaendig.
2. NEEDRESTART_MODE=a + DEBIAN_FRONTEND=noninteractive fuer alle apt-Aufrufe.
3. </dev/null an jedem Paketmanager (apt, dnf, yum, apk).
2026-09-14 10:14:10 +02:00
mm 0e94bf4d91 docs: Error-Log-Erfassung dokumentiert (LogQL-Beispiele, drei stille Fallen) 2026-09-14 10:03:21 +02:00
mm 3236d9cede feat(apache): Error-Logs erfassen + drei stille Fallen behoben (VERSION 1.3.0)
Bisher wurden NUR access.log gesammelt — real nachgemessen: 31 logfile-Labels,
0 davon error.log. Bei einem KeyHelp-Host mit PHP/CMS fehlten damit genau die
Fehler, fuer die man ins Monitoring schaut.

Error-Logs jetzt in einem EIGENEN Input (name_override=apache_error), roh nach
Loki, mit Tags logtype=error und logfile=<pfad>. Getrennt vom Access-Grok, weil
das Format nicht passt:
  [Mon Sep 14 00:10:01.123456 2026] [core:error] [pid 9] AH00124: ...
Liefen sie durch den Access-Parser, entstuenden keine Metriken, aber der
Aggregator wuerde sie in leere Gruppen einordnen.

DREI STILLE FALLEN dabei gefunden und behoben:
1. processors.rename hatte namepass=["journal"] — apache_error behielt das
   Feld value, jede Panel-Query mit {.message} haette es nicht gefunden.
2. outputs.loki namepass enthielt apache_error nicht — Input sammelt ein,
   Output verwirft, keine Fehlermeldung, Panels leer (wie bei journal damals).
3. outputs.prometheus namedrop ebenso — sonst wandern Textzeilen in die TSDB.

Verifiziert gegen echte KeyHelp-Formate: access=2 Metriken, error=4 Rohzeilen,
Feld heisst message, 0 Error-Zeilen als Metrik.
2026-09-14 10:03:13 +02:00
mm 6d0d0bd900 fix(apache): Aggregator gegen Zeitstempel-Kollisionen + README korrigiert
Die vorige Massnahme (log_time statt ts) war NICHT ausreichend. Ursache ist
tiefer: Telegraf schreibt pro Sammelzyklus EINEN Zeitstempel fuer den ganzen
Batch, und Remote Write kann nur Millisekunden. Zwei Requests derselben Serie
in derselben Millisekunde -> Prometheus lehnt den ganzen Batch ab -> FIFO-Kopf
sitzt fest -> Node liefert gar nichts mehr (derselbe eingefrorene Timestamp
alle ~15s im Log).

Ein Zeitstempel-Trick kann das nicht loesen. Aggregation schon: basicstats
fasst alle Metriken einer Serie pro 60s zu einem Punkt zusammen. Gemessen an
2000 echten x22-Zeilen: 2000 Punkte -> 4 Punkte, 0 Kollisionen, Requests
vollstaendig gezaehlt (1997).

VERSION 1.2.0, damit der Auto-Updater den Fix ausrollt.
2026-09-14 02:01:13 +02:00
mm a9d9b92499 fix(apache): Aggregator gegen Zeitstempel-Kollisionen (Wurzel, nicht Symptom) 2026-09-14 01:59:44 +02:00
mm d684ce860d fix(installer): --update-only ohne --token/--name auf bestehender Node
Zwei Fehler, gleiche Wurzel — die Werte aus /etc/default/telegraf wurden nur
vom Auto-Updater gelesen, nicht von einem manuellen --update-only:

1. Abbruch "Kein Token", obwohl der Token auf der Maschine liegt. Der Weg
   fuer eine Reparatur auf einer laufenden Node war damit verschlossen.
2. Stiller: ohne --name fiel NODE_NAME auf den Hostnamen zurueck. Auf einem
   KeyHelp-Host (x22 vs x22.idx.wf-keyhelp) waere die Node unter falschem
   Label registriert worden; ein PVE-Knoten haette seine Rolle verloren
   (kein pvesh-Drop-in mehr).

Fix: Token, NODE_NAME, ROLE, COLLECTOR, PVE_* aus der Datei nachladen, aber
nur wenn die Kommandozeile nichts anderes sagt (NAME_GIVEN/ROLE_GIVEN).

Zusaetzlich ein zweiter, ernster Fund aus dem Test:
APACHE_FILES blieb leer, wenn ROLE=apache gesetzt war, Apache aber nicht
erkannt wurde. Die Conf rendert dann zu "files = " -> ungueltiges TOML ->
Telegraf startet nicht -> ALLE Metriken der Node aus. Berechnung jetzt
unabhaengig vom Guard, plus Sicherheitsnetz vor dem Rendern.
2026-09-14 01:34:15 +02:00
mm 24c04f8eff docs: Backfill-Weg fuer alte Logs (messen -> archivieren -> nur ins Archiv einspielen) 2026-09-14 01:29:01 +02:00
mm 4da9a59527 feat(archive): x22-Log-Archiv-Skript (messen + Backfill, mit Selbstpruefung) 2026-09-14 01:27:31 +02:00
mm 49f5dbc335 chore: __pycache__ nicht committen 2026-09-14 01:25:55 +02:00
mm 5483a4fea8 fix(apache): Sekunden-Kollision behoben (log_time + converter) — Node lieferte gar nichts mehr
Echte x22-Logs: 2000 Zeilen -> 286 Kollisionen (duplicate sample).
Telegraf uebernimmt den Log-Zeitstempel; bei mehreren Treffern pro Sekunde
kollidiert der +1ms-Aufschlag mit echten Zeilen. Prometheus weist den Batch
ab, Telegraf retryt endlos -> FIFO steht -> Node liefert GAR NICHTS mehr.

Fix: eigenes Grok-Muster mit log_time als Feld (Telegraf nutzt eigene ns-Uhr)
+ processors.converter, damit resp_code/verb Tags bleiben.
Gemessen: 2000/2000 Samples, 0 Kollisionen.

Ausserdem: Einheiten-Waechter im Archiver (ms statt s -> Abbruch statt
nutzloser Block).
2026-09-14 01:25:51 +02:00
mm 5a213eb580 docs: ACL-Loesung fuer User-Home-Logs + Glob-Muster dokumentiert 2026-09-14 00:45:01 +02:00
mm 3c0c800f90 fix(apache): Glob-Muster in der Conf behalten (set -f statt Expansion) + ACL fuer User-Home-Logs (KeyHelp) + Diagnose entdoppelt 2026-09-14 00:43:39 +02:00
mm 911823514d docs: --diagnose dokumentiert 2026-09-14 00:36:46 +02:00
mm 963546641b fix(diagnose): Quoting des Pfads im Lesbarkeitstest 2026-09-14 00:35:59 +02:00
mm 711d8bdc1d feat(diagnose): --diagnose prueft Leserechte, leere Dateien, Parser und Offset in einem Aufruf 2026-09-14 00:35:11 +02:00
mm ce52d2c26b docs: Apache-Logpfad-Sammlung (KeyHelp zweistufig) + Quoting-Pflicht in /etc/default/telegraf 2026-09-14 00:32:24 +02:00
mm cba503e174 fix(installer): /etc/default/telegraf quoten + robustes Sourcing (load_defaults); apache-Logpfade sammeln statt ersten nehmen 2026-09-14 00:32:07 +02:00
mm d614ffa56a fix(apache): KeyHelp-Pfadmuster /home/users/*/logs/*/access.log (zweistufig) + Plesk/cPanel 2026-09-14 00:26:49 +02:00
mm 2b853529f9 fix(apache): Logpfade als TOML-Liste + CustomLog-Erkennung zuerst (KeyHelp-VHost-Logs) 2026-09-14 00:24:31 +02:00
mm 33eb5f2308 fix(installer): Konfig-Test ohne --input-filter — validierte nur gefilterte Plugins, Dienststart scheiterte danach 2026-09-14 00:21:05 +02:00
mm 02af5b428f feat(apache): Glob ueber VHost-Logs fuer Panel-Hoster (KeyHelp/Plesk/cPanel) 2026-09-14 00:16:42 +02:00
mm da3f221c8d fix(apache): from_beginning existiert bei inputs.tail nicht (Dienststart scheiterte) + COMMON/COMBINED_LOG_FORMAT; Logpfad-Autoerkennung 2026-09-14 00:14:45 +02:00
mm feb77e2fc7 fix: TELEGRAF_OPTS leer setzen — beseitigt systemd-Warnung "Referenced but unset" 2026-09-13 23:56:39 +02:00
mm b0f8cb860d docs: sudo-Regel verallgemeinert — PVE, x17/Root-Server, LXC brauchen kein sudo 2026-09-13 23:52:56 +02:00
mm cc5dc6b9ad fix(updater): nur eigene Confs entfernen — fremde/manuelle Confs ueberleben Update 2026-09-13 23:51:16 +02:00
26 changed files with 3936 additions and 40 deletions

No files matched your search

+2 -2
View File
@@ -1,2 +1,2 @@
*.swp
.DS_Store
__pycache__/
*.pyc
+414 -2
View File
@@ -5,6 +5,291 @@ Der Ausgang läuft über den **NetBird-Reverse-Proxy** (`imac-prometheus.x05.idx
mit **Bearer-Token** — dadurch funktioniert der Installer auch auf Maschinen, die **kein NetBird-Peer** sind
(Büro-LAN, Kundenlaptop, CGNAT, VM ohne Client).
## Installation — der sichere Weg
```sh
# Empfohlen: erst herunterladen, dann ausführen
curl -fsSL <raw>/install.sh -o /tmp/netbird-install.sh
sudo sh /tmp/netbird-install.sh --name <node> --token <TOKEN>
```
**Warum nicht direkt `curl … | sh`?** Bei einer Pipe ist **stdin das Skript
selbst**. Jeder Unterbefehl, der stdin liest, kann es wegräumen — und der
Installer ruft Paketmanager auf (`apt-get`), die genau das tun. Auf Ubuntu
startet dabei `needrestart`:
```
Scanning processes...
Scanning linux images...
```
Danach setzt die Shell mitten im **Quelltext** fort statt im Skript — sichtbar
an Ausgaben wie `; then`, `fi` oder Kommentarzeilen. Der Lauf bricht ab, die
Node ist halb konfiguriert. **Auf Nodes mit bereits installiertem Telegraf
passiert das nicht**, weil dann kein Paketmanager läuft — dort funktioniert
`curl … | sh` scheinbar problemlos.
Ab **1.4.0** schützt sich der Installer selbst: erkennt er eine echte Pipe,
lädt er sich frisch von der Quelle, startet sich mit `stdin </dev/null` neu und
meldet das:
```
[netbird-telegraf] Selbstschutz: Skript laeuft jetzt von /tmp/… (stdin frei).
```
Fehlen `curl`/`wget` auf einem frischen System, meldet er das ausdrücklich
(`HINWEIS: kein curl/wget fuer den Selbstschutz`) — dann ist der Download-Weg
oben die sichere Wahl.
**Der Fehler ist reproduzierbar:** `x14` brach genau so ab. Der Installer lief
bis `Node=… Apache=1`, dann kamen `Scanning processes…`-Zeilen, danach
Quelltext-Ausgaben, und `apache.conf` fehlte am Ende.
## Wenn der Lauf hängt
Der Installer darf **nie unbegrenzt warten**. Alle Netzwerkaufrufe haben
Timeouts, und die ACL-Phase meldet sich vorher:
```
[netbird-telegraf] Log-Leserechte: pruefe/ergaenze ACL fuer 'telegraf' (kann etwas dauern) …
```
Diese Zeile dauert auf einem frischen System bis zu **3 Minuten** — dort läuft
`apt-get update` + `apt-get install acl`. Erscheint sie und danach nichts,
nicht sofort abbrechen: bis 3 Minuten ist normal.
| Aufruf | Timeout | Versuche |
|---|---|---|
| `fetch` (Confs/Scripts vom Repo) | 30 s | 3 |
| `apt-get update` / `install acl` | 180 s | 1 |
| InfluxData-Keyring | 30 s | 1 |
| Telegraf-Binary (statisch) | 120 s | 1 |
| Selbstschutz-Download | 30 s | 1 |
**Vor 1.5.0 gab es diese Timeouts nicht** — `fetch()` wartete unbegrenzt. Symptom:
der Lauf bleibt nach der letzten Erfolgsmeldung stehen, ohne Fehler; man bricht
mit `^C` ab und weiß nicht, wo es hing. Real passiert auf `x14`.
## Diagnose: „Logs kommen nicht an"
Ein Befehl zeigt, warum:
```sh
curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/install.sh \
| sh -s -- --diagnose
```
Er prüft genau die Stellen, die schon **still** versagt haben (kein `E!` in keinem Log):
| Prüfung | Warum |
|---|---|
| Dienst-Benutzer + Gruppen | Telegraf läuft als `telegraf`, nicht als root |
| Jede Datei aus der `files`-Liste | Existenz, Größe, **Lesbarkeit aus Sicht des Dienst-Benutzers** |
| Parser-Lauf ohne Filter | echte `E!`-Zeilen sichtbar machen |
| Offset-Zustand | erklärt, warum eine bestehende Datei „leer" wirkt |
Beispielausgabe:
```
-- Logpfade aus der aktiven Conf --
OK /var/log/apache2/keyhelp/access.log (75 Bytes)
NICHT LESBAR /home/users/u1/logs/d1/access.log (Benutzer telegraf) ← URSACHE
-> 3 Pfade, 2 lesbar, 0 leer, 1 nicht lesbar
```
**Wichtig:** `initial_read_offset = "saved-or-end"` heißt, dass beim **ersten** Lauf nur **Neues** gelesen wird. Bestehende Zeilen kommen erst mit neuen Zugriffen — für einen sofortigen Test eine Seite aufrufen und ein Intervall abwarten.
## Der Konfig-Test prüft ALLE Dateien
Ein Detail, das schon eine Node lahmgelegt hat: **Nie mit `--input-filter` prüfen.**
```sh
# FALSCH — validiert nur die gefilterten Plugins:
telegraf --config … --config-directory … --test --input-filter cpu:mem
# RICHTIG — parst alle Dateien, genau wie der Dienststart:
telegraf --config … --config-directory … --test --test-wait 1
```
Mit `--input-filter cpu:mem` überspringt Telegraf alle übrigen Konfigdateien. Ein ungültiges Feld in `telegraf.d/apache.conf` lief damit durch, der Installer meldete **„OK — Node liefert"**, und erst der Dienststart scheiterte danach mit:
```
Job for telegraf.service failed because the control process exited with error code.
```
Der Installer prüft deshalb ohne Filter und wertet nur echte Parser-Fehler (`E! … loading config file`) als Abbruch — nicht jede Warnung, denn ein fehlendes Input-Binary (`nvidia-smi`) zeigt sich erst beim Start, nicht beim Parsen.
## Apache-Logs
Mit `--role apache` (oder automatisch, wenn `apache2ctl`/`httpd` gefunden wird) bindet der Installer Apache ein — auf **zwei** Wegen aus derselben Datei:
| Ziel | Was | Wofür |
|---|---|---|
| **Loki** | jede Zeile roh | Fehlersuche im Einzelfall |
| **Prometheus** | `apache_access` mit Tags `resp_code`, `verb` | Request-Rate, Status-Klassen, Error-Rate — Panels und Alerts |
### Logpfade werden automatisch gesammelt
Der Installer **sammelt alle passenden Muster** — nicht nur das erste. Auf einem KeyHelp-Host laufen zwei Strukturen parallel, und die globale Standarddatei ist leer:
| Panel | Pfadmuster |
|---|---|
| **KeyHelp** | `/home/users/<user>/logs/<domain>/access.log` (**zweistufig**, pro Domain) |
| **KeyHelp** | `/var/log/apache2/<vhost>/access.log` (z. B. `keyhelp/access.log`) |
| Plesk | `/var/www/vhosts/<domain>/logs/access_log` |
| cPanel | `/usr/local/apache/domlogs/<domain>` |
| Klassisch | `/var/log/apache2/access.log`, `/var/log/httpd/access_log` |
Verifiziert auf x22: Die Requests liegen unter `/home/users/classi/logs/classi.parts/access.log` **und** `/var/log/apache2/keyhelp/access.log`, während `/var/log/apache2/access.log` **0 Bytes** hat. Ein Input auf die leere Datei hätte still nichts geliefert — leere Standarddateien werden per `-s`-Test ausgeschlossen.
Alle gefundenen Pfade landen als TOML-Liste in der Conf:
```toml
files = ["/var/log/apache2/*/access.log", "/home/users/*/logs/*/access.log"]
```
Jede Serie trägt ein `path`-Tag, damit man in Grafana nach Datei filtert. Überschreiben mit `--apache-log <pfad>` (mehrere Pfade leerzeichengetrennt).
**Zusätzlich** wird die `CustomLog`-Direktive aus `/etc/apache2` bzw. `/etc/httpd` ausgewertet — die verlässlichste Quelle, `${APACHE_LOG_DIR}` wird aufgelöst.
### Zeitstempel-Kollisionen: der stille Totalausfall
**Symptom.** Ein Node liefert **gar nichts** mehr — nicht nur die Apache-Metriken,
sondern auch `system_uptime`, CPU, Disk. Andere Nodes laufen normal.
**Erkennungsmerkmal.** Im Prometheus-Log wiederholt sich **derselbe eingefrorene
Timestamp** alle ~15 s:
```
duplicate sample for timestamp 1789343466232; overrides not allowed:
existing 43930, new value 51608
series="{__name__=\"apache_access_resp_bytes\", path=\"…/pixtasia.de/access.log\",
resp_code=\"200\", verb=\"GET\"}"
```
Derselbe Wert über Minuten heißt: **ein vergifteter Batch sitzt im FIFO-Kopf und
wird endlos wiederholt** — nicht ein neuer Fehler pro Intervall.
**Ursache.** Telegraf schreibt pro Sammelzyklus einen Batch, dessen Metriken alle
denselben Zeitstempel tragen. Remote Write kennt nur **Millisekunden**. Treffen
zwei Requests derselben Serie (`path` + `resp_code` + `verb`) in dieselbe
Millisekunde, lehnt Prometheus den **ganzen Batch** ab. Telegrafs Puffer ist
FIFO: der Batch bleibt am Kopf stehen und blockiert jede weitere Serie — deshalb
fehlt am Ende die ganze Node.
**Lösung: Aggregation statt Zeitstempel-Trick.** Ein Zeitstempel-Trick kann das
nicht beheben — feiner als Millisekunden geht Remote Write nicht. Ein Aggregator
fasst alle Metriken einer Serie pro Intervall zu **einem** Punkt zusammen; damit
ist eine Kollision strukturell unmöglich, unabhängig von der Last:
```toml
[[aggregators.basicstats]]
namepass = ["apache_access"]
period = "60s"
drop_original = true # zwingend — sonst sind die Einzelwerte zurück
stats = ["count", "sum", "mean"]
```
**Gemessen an 2000 echten x22-Zeilen:**
| | Punkte | Kollisionen |
|---|---|---|
| ohne Aggregator | 2000 | Risiko pro Zelle |
| **mit Aggregator** | **4** | **0** |
Die Requests werden dabei vollständig gezählt (1997 von 2000 — Differenz durch
das Zeitfenster).
**Entstehende Metriken** (Telegraf stellt den Feldnamen voran):
| Metrik | Bedeutung |
|---|---|
| `apache_access_resp_bytes_count` | Requests je Gruppe — **das brauchen die Panels** |
| `apache_access_resp_bytes_sum` | übertragene Bytes je Gruppe |
| `apache_access_resp_bytes_mean` | mittlere Antwortgröße |
`resp_code` und `verb` bleiben **Tags** und damit Gruppenschlüssel — dafür sorgt
`processors.converter` oben im File (das eingebaute Muster liefert sie als Tags,
ein eigenes Muster als Felder; ohne den Converter brächen Filter nach `resp_code`).
**Diagnose.**
```sh
# derselbe Timestamp mehrfach = vergifteter Batch
docker logs observability-prometheus 2>&1 \
| grep "duplicate sample" | grep -oE "timestamp [0-9]+" | sort | uniq -c | tail
```
### Leserechte: der stille Killer
Auf x22 waren **64 von 84 Logpfaden für `telegraf` unlesbar** — `inputs.tail` meldet das **nicht**, es liefert einfach nichts, und der Node sieht „aktiv" aus. Zwei verschiedene Rechte-Welten:
| Ort | Rechte | Was hilft |
|---|---|---|
| `/var/log/apache2/…` | `root:adm 0640` | `usermod -aG adm telegraf` |
| `/home/users/<user>/logs/…` | `<user>:<user> 0640` | **`adm` hilft nicht** — ACL nötig |
Der Installer setzt dafür **ACLs** (`setfacl -R -m u:telegraf:rX` plus eine Default-ACL, damit neue Domain-Ordner das Recht erben). Das `acl`-Paket wird bei Bedarf nachinstalliert. Der Dienst bleibt im eingeschränkten Konto — kein `root` nötig.
Danach folgt ein **Gegentest**: Der Installer liest eine echte Logdatei als Dienst-Benutzer. Meldet er
```
[netbird-telegraf] Gegentest: 'telegraf' kann Apache-Logs lesen (…)
```
ist die Kette nachweislich in Ordnung.
### Glob-Muster statt Einzelpfade
Die Conf enthält **Muster**, nicht die expandierten Dateinamen:
```toml
files = ["/var/log/apache2/*/access.log", "/home/users/*/logs/*/access.log"]
```
Damit wird eine **neu angelegte Domain sofort erfasst**, ohne dass der Installer erneut laufen muss. (Der Installer schaltet beim Rendern `set -f` ein — sonst würde die Shell die Muster selbst auflösen und feste Pfade in die Conf schreiben.)
### `/etc/default/telegraf` wird gesourct — Werte müssen gequotet sein
Diese Datei wird mit `. /etc/default/telegraf` eingelesen (vom Installer und von der Telegraf-Unit). Ein **ungequoteter** Wert mit Leerzeichen bricht das Sourcing ab — bei mehreren Logpfaden versucht die Shell, den zweiten Pfad als Befehl auszuführen:
```
sh: /etc/default/telegraf: /home/users/classi/logs/classi.parts/access.log: Permission denied
```
Der Installer schreibt `APACHE_LOG='…'` deshalb **mit einfachen Anführungszeichen**. Zusätzlich liest er die Datei über `load_defaults` ein, das syntaktisch kaputte Dateien sichert und überschreibt statt abzubrechen — eine Node lässt sich so auch dann reparieren, wenn ihre Defaults-Datei bereits defekt ist.
### Zwei Fallstricke
**1. `from_beginning` existiert bei `inputs.tail` nicht.** Ein solcher Block lässt den Dienst beim Start hart scheitern — `systemctl start` schlägt fehl und **alle** Metriken fallen aus, nicht nur die Apache-Logs:
```
E! loading config file /etc/telegraf/telegraf.d/apache.conf failed:
plugin inputs.tail: line 2: configuration specified the fields ["from_beginning"],
but they were not used ... does not exist in this version
```
Richtig ist `initial_read_offset = "saved-or-end"` (Default) — damit wird beim ersten Lauf nicht die komplette Historie ausgeschüttet. Für einen Test gegen eine vorgefüllte Datei `"beginning"` setzen.
**2. Die Grok-Muster heißen `COMMON_LOG_FORMAT` und `COMBINED_LOG_FORMAT`** — nicht `HTTPD_COMMON_LOG`/`HTTPD_COMBINED_LOG_FORMAT`. Die `HTTPD_*`-Namen aus anderen Grok-Sammlungen kennt Telegraf nicht:
```
E! [inputs.tail] Creating parser: no pattern found for %{HTTPD_COMBINED_LOG_FORMAT}
```
Die Reihenfolge in `grok_patterns` ist spezifisch → allgemein, damit die VHost-Varianten (`example.com:80 ...`) vor der einfachen Form greifen.
### Response-Time
Common und Combined Log Format enthalten **keine** Antwortzeit. Für `resp_time` in den Metriken muss das LogFormat in Apache erweitert werden:
```apache
LogFormat "%h %l %u %t \"%r\" %>s %b \"%D\" \"%{User-agent}i\"" combined_time
CustomLog ${APACHE_LOG_DIR}/access.log combined_time
```
`%D` = Dauer in Mikrosekunden. Danach im Grok-Muster als Feld ergänzen. Ohne diese Änderung sind alle anderen Werte trotzdem verfügbar — nur die Antwortzeit fehlt.
## Auto-Updater und Force-Updater
Der Installer hält sich auf den Nodes selbst aktuell.
@@ -158,7 +443,9 @@ curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/i
| `--apache-log <pfad>` | Access-Log (Default `/var/log/apache2/access.log`) |
| `--uninstall` | alles entfernen |
**Root-Rechte:** Das Script braucht sie (Dienst, `/etc/telegraf`). Auf PVE, in LXC-Containern und überall, wo du schon root bist: **kein `sudo`** — es ist dort oft nicht installiert. Auf normalen Desktops/Servern mit eigenem Benutzer: `| sudo sh -s -- …`.
**Root-Rechte:** Das Script braucht sie (Dienst, `/etc/telegraf`). Wo du **schon root** bist — PVE-Knoten, Hetzner-/Root-Server wie x17, LXC-Container, minimale Debian-Installationen — **kein `sudo`**: dort ist es oft gar nicht installiert und der erste Befehl bricht mit `sudo: command not found` ab. Auf normalen Desktops/Servern mit eigenem Benutzer (Ubuntu-Desktop, ROG): `| sudo sh -s -- …`.
**Prüfen statt raten:** `id -u` — kommt `0`, ohne `sudo`. Das Script prüft es selbst und bricht mit klarer Meldung ab, wenn es nicht root ist.
Ablauf: Paketquelle von InfluxData (apt/dnf/apk) oder statisches Binary (amd64/arm64/armv7) → `/etc/telegraf/telegraf.conf` + `telegraf.d/` aus diesem Repo → `/etc/default/telegraf` mit NODE_NAME, PROM_URL, LOKI_URL, TELEGRAF_TOKEN → systemd-Dienst `telegraf`, enabled, Konfig-Test vor dem Start.
@@ -180,7 +467,7 @@ Token-/ACL-Thematik.
> eigene Rolle mit `Sys.Audit`) blieben auf PVE 8 mit `403` erfolglos.
Dann auf **jedem PVE-Knoten** (auf dem Host selbst, nicht in einer VM).
**Auf PVE gibt es kein `sudo`** — du bist dort bereits root, also ohne `sudo`:
**Auf PVE (und auf Root-Servern wie x17) gibt es kein `sudo`** — du bist dort bereits root, also ohne `sudo`:
```sh
curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/install.sh \
@@ -258,6 +545,131 @@ Wer kein sudo hat, aber in der Gruppe `docker` ist: Container-Variante aus
[netbird-observability/stacks/telegraf-node](https://gitea.m-m.bio/mm/netbird-observability) (privates Repo).
Dort ist `network_mode: host` gesetzt — ohne das sieht Telegraf nur `lo`+`eth0` statt der echten Interfaces.
## llama-swap-Telemetrie ohne sudo (Nutzer-Instanz)
Für Knoten, die eine lokale Inferenz-Landschaft über **llama-swap** anbieten und
**kein sudo** haben (ROG). Die System-Instanz dieses Installers kann dort nicht
greifen — und sie würde llama-swap auch nicht sichtbar machen: llama-swap läuft
als **User**-Unit, seine INFO-Zeilen liegen im Nutzer-Journal, und
`journal-tail.sh` liest das Systemjournal mit `priority >= warning`. Ergebnis
vorher: null llama-swap-Zeilen in Loki.
```sh
# auf dem Zielknoten, als der Nutzer, dem llama-swap gehört
sh scripts/install-llamaswap-user.sh
# Optionen: --raw <basis-url> (Dateien vom Repo laden) --dry-run
```
Was die Nutzer-Instanz holt:
| Quelle | Weg | Inhalt |
|---|---|---|
| `llama-swap:9300/metrics` | `inputs.prometheus` | Host (CPU je Kern, RAM, Netz), GPU (Auslastung, VRAM, Temperatur, Leistung) |
| `/api/metrics/stats?model=<id>` | `inputs.exec` → `llamaswap-telemetrie.py` | Token/Anfragen/Cache **je Modell** — auch für Modelle ohne `/metrics` (strata-Engine, sd-server) |
| `http://<upstream>/metrics` | dito | Token je **laufendem** Profil aus llama.cpp (nur mit `--metrics`) |
| `journalctl --user-unit …` | `inputs.exec` → `llamaswap-logs.py` | Logs von llama-swap und kalina-backends für Loki |
Voraussetzungen, die llama-swap selbst erfüllen muss:
* **`--metrics` in den Startbefehlen** — llama.cpp liefert `/metrics` nur mit
diesem Schalter (sonst HTTP 501). Für `model-manager.sh`, die Inline-`cmd`s in
`llama-swap.yaml` und eigene Startskripte.
* **`store.path` in `llama-swap.yaml`** — ohne Datei-Store hält llama-swap die
Aktivität nur im RAM und kürzt sie nach `metricsMaxInMemory`; Token-Summen
wären dann ein rollendes Fenster. Mit Store sind sie dauerhaft (SQLite).
Zugangsdaten bleiben dort, wo der Knoten-Installer sie hingelegt hat:
`EnvironmentFile=/etc/default/telegraf` für die Unit, `${PROM_URL}` /
`${LOKI_URL}` / `${TELEGRAF_TOKEN}` in der Konfiguration. Kein Kopieren von
Token. Ohne `linger` endet die Unit mit der Sitzung — `loginctl enable-linger $USER`.
Grafana: Dashboard **llama-swap (ROG)** (`uid: llama-swap-rog`) in
`netbird-observability/stacks/central-imac/dashboards/llama-swap.json`.
## Error-Logs (seit 1.3.0)
Apache schreibt **zwei** Logarten mit **verschiedenen** Formaten:
| Datei | Format | Ziel |
|---|---|---|
| `access.log` | `IP ident auth [HTTPDATE] "VERB REQ" CODE BYTES` | Loki (roh) **und** Prometheus (Metriken) |
| `error.log` | `[Mon Sep 14 00:10:01.123456 2026] [core:error] [pid 9] AH00124: …` | Loki (roh), Tag `logtype=error` |
Bis 1.2.0 wurden **nur** Access-Logs gesammelt. Nachgemessen: 31 `logfile`-Labels
in Loki, **0** davon `error.log` — auf einem Panel-Host mit PHP/CMS fehlten damit
genau die Fehler, für die man ins Monitoring schaut.
Error-Logs laufen über einen **eigenen Input**, nicht durch den Access-Grok: das
Format passt nicht, und der Aggregator würde sie sonst in leere Gruppen einordnen.
Sie tragen `logtype=error` und `logfile=<pfad>` — in Grafana nach beiden filterbar:
```logql
{node="<host>", logtype="error"}
{node="<host>", logtype="error"} | logfmt | line_format "{{.message}}" |~ "PHP Fatal"
```
### Drei stille Fallen, die dabei behoben wurden
Alle drei hätten dazu geführt, dass die Logs **gesammelt und dann verworfen**
werden — ohne Fehlermeldung:
1. `processors.rename` hatte `namepass = ["journal"]`. Für `apache_error` hieß
das Feld deshalb `value` statt `message` — jede Query mit `{{.message}}` hätte
es nicht gefunden.
2. `outputs.loki` hatte `namepass = ["docker_log", "apache_log", "journal"]` —
ohne `apache_error`. Der Input sammelt ein, der Output verwirft.
3. `outputs.prometheus` fehlte `apache_error` in `namedrop` — sonst wandern
Textzeilen als Zeitreihen in die TSDB.
## Alte Logs archivieren (Historie nachträglich auswertbar machen)
`inputs.tail` liest mit `initial_read_offset = "saved-or-end"` nur **Neues** — die
vorhandene Historie bleibt unausgewertet. Dafür gibt es ein eigenes Skript, das
dieselben Zeilen parst und als Prometheus-Blöcke ablegt:
```sh
# Auf der Node: erst messen (ändert nichts)
curl -fsSL <raw>/scripts/x22-log-archive.sh -o /root/archiv.sh
bash /root/archiv.sh # Bestandsaufnahme
# dann archivieren
bash /root/archiv.sh --backfill # erzeugt apache-archive.openmetrics + tar.gz
```
Das Skript prüft seine eigene Ausgabe **vor** der Übergabe: Zeitraum, Einheit
(Sekunden) und ein **Zukunfts-Check** — Punkte nach „jetzt" werden gemeldet, weil
sie später beim Import die Zeitreferenz verschieben würden.
Eingespielt wird **nur in die Archiv-Instanz**, nie in die Produktions-TSDB:
```sh
scp /root/apache-archive/apache-archive.openmetrics mm@<imac>:/tmp/
ssh mm@<imac> 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/apache-archive.openmetrics'
```
Geparst werden Access- **und** Error-Logs, inklusive rotierter `.log.1`/`.log.N.gz`.
Was entsteht: `apache_archive_requests_total` (je Domain/Status/Methode/Stunde),
`_response_bytes_total`, `_unique_clients`, `_errors_total` (je Level),
`_log_lines_total`. Als Datasource in Grafana einhängen
(`http://archive-prometheus:9090`) — Live und Archiv sind dann getrennt abfragbar.
## Archiv-Import: Schutzschichten
Alte Logs lassen sich als Prometheus-Blöcke archivieren (`scripts/apache-log-archive.py`).
Der Import in eine **laufende** TSDB ist heikel — drei Fehler sind möglich, alle
still:
| Fehler | Folge | Schutz |
|---|---|---|
| Zeitstempel in **Millisekunden** statt Sekunden | Blöcke im Jahr 58.662, Prometheus verwirft sie als „too far into the future" | Einheiten-Wächter im Skript (Abbruch ab Jahr 2100) |
| Block reicht in die **Zukunft** | Prometheus verschiebt seine Zeitreferenz, **alle** Live-Samples gelten als „too old" — alle Nodes fallen aus | Zukunft-Prüfung in `prom-archive-import.sh` (Abbruch, Toleranz 5 min) |
| **`rm -rf <tsdb>/01M*`** beim Aufräumen | Glob trifft **alle** Blöcke → gesamte Historie gelöscht | kein Glob; nur exakte Block-IDs; Pflicht-Snapshot vorher |
Deshalb: **Archiv-Blöcke nie in die Produktions-TSDB**, sondern in die getrennte
Instanz (`stacks/archive-prometheus`, Port 9091). Sie nimmt keine Live-Daten an
und kann die Produktion nicht beeinflussen.
## Sicherheit
- Der Proxy verlangt Bearer-Token; das Header wird vor dem Weiterleiten entfernt und liegt damit nicht bei Prometheus/Loki.
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.9.4
+586 -24
View File
@@ -1,5 +1,65 @@
#!/usr/bin/env sh
# ============================================================================
# SELBSTSCHUTZ BEI "curl | sh"
#
# Problem (real passiert, x14): Bei `curl … | sh` liest die Shell das Skript aus
# STDIN. Jeder Unterbefehl, der stdin genauso liest, frisst den Rest des
# Skripts — die Shell setzt dann mitten im Quelltext fort und bricht ab.
# Ausloeser ist apt/dpkg: auf Ubuntu laeuft dabei `needrestart`, und das
# meldet sich mit "Scanning processes... / Scanning linux images...".
# Symptom in der Praxis: Der Installer gibt seinen EIGENEN Quelltext aus
# ("; then", "fi", Kommentarzeilen) und bleibt mitten in der Installation stehen.
# Auf Nodes mit bereits installiertem Telegraf tritt das NICHT auf (kein apt).
#
# Loesung: Laeuft das Skript ueber eine Pipe, kopiert es sich EINMAL auf die
# Platte und startet sich von dort neu. Ab da ist stdin frei und apt kann
# nichts mehr verschlucken. Idempotent: der Neustart setzt die Marker-Variable.
# ============================================================================
# Nur bei einer ECHTEN PIPE (curl … | sh) neu starten. Bei `sh install.sh`
# oder `sh install.sh < eingabe` ist stdin eine Datei — dort kann kein
# Unterbefehl das Skript wegfressen, und der Re-Exec waere nur ein
# unnoetiger Download.
if [ -z "${NETBIRD_TG_REEXEC:-}" ] && [ ! -t 0 ] && [ -p /dev/stdin ]; then
# Das Skript wird NICHT per `cat` von stdin kopiert: die Shell liest stdin
# blockweise und hat den Anfang bereits gepuffert — `cat` bekaeme nur den
# Rest und die Kopie waere unvollstaendig. Stattdessen frisch von der Quelle
# laden (dieselbe URL, die der Aufrufer benutzt hat).
_src="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
_self="$(mktemp /tmp/netbird-telegraf-install.XXXXXX 2>/dev/null || echo /tmp/netbird-tg.$$)"
# WICHTIG: auf einem FRISCHEN System gibt es oft kein curl — genau dort, wo
# der Schutz am noetigsten ist (dort laeuft apt und damit needrestart).
# Deshalb BEIDE Wege versuchen und, wenn keiner geht, das Skript weiter
# ausfuehren wie bisher (dann bleibt das Risiko, aber es laeuft).
_dl=0
if command -v curl >/dev/null 2>&1; then
curl -fsSL -m 30 --connect-timeout 10 "$_src/install.sh" -o "$_self" 2>/dev/null && [ -s "$_self" ] && _dl=1
fi
if [ "$_dl" -eq 0 ] && command -v wget >/dev/null 2>&1; then
timeout 30 wget -q -O "$_self" "$_src/install.sh" 2>/dev/null && [ -s "$_self" ] && _dl=1
fi
if [ "$_dl" -eq 0 ] && command -v busybox >/dev/null 2>&1; then
busybox wget -q -O "$_self" "$_src/install.sh" 2>/dev/null && [ -s "$_self" ] && _dl=1
fi
# Letzter Weg: liegt das Skript schon lokal (z. B. per scp), von dort kopieren.
if [ "$_dl" -eq 0 ] && [ -n "${NETBIRD_TG_SOURCE:-}" ] && [ -s "${NETBIRD_TG_SOURCE:-}" ]; then
cp "${NETBIRD_TG_SOURCE}" "$_self" 2>/dev/null && _dl=1
fi
if [ "$_dl" -eq 1 ]; then
chmod 0700 "$_self"
printf '[netbird-telegraf] Selbstschutz: Skript laeuft jetzt von %s (stdin frei).\n' "$_self"
# stdin ausdruecklich auf /dev/null: der Prozess erbt sonst die Pipe, und
# apt/needrestart koennte weiter daraus lesen.
NETBIRD_TG_REEXEC=1 REPO_RAW="$_src" sh "$_self" "$@" < /dev/null
_rc=$?
rm -f "$_self"
exit "$_rc"
fi
printf '[netbird-telegraf] HINWEIS: kein curl/wget fuer den Selbstschutz — weiter ohne.\n'
rm -f "$_self"
fi
# ============================================================================
# NetBird Observability — Telegraf Node Installer (Linux)
#
# Installiert Telegraf als systemd-Dienst und verbindet die Node fest mit dem
@@ -41,16 +101,24 @@ COLLECTOR="${COLLECTOR:-}" # nur setzen fuer den Direktweg im NetBird
TELEGRAF_TOKEN="${TELEGRAF_TOKEN:-}"
NODE_NAME="${NODE_NAME:-$(hostname -s 2>/dev/null || hostname)}"
ROLE="auto"
# Wurden --name/--role auf der Kommandozeile gesetzt? Nur dann darf ein
# Update-Lauf die in /etc/default/telegraf hinterlegten Werte ueberschreiben.
NAME_GIVEN=0
ROLE_GIVEN=0
PVE_TOKEN="${PVE_TOKEN:-}"
PVE_NODE="${PVE_NODE:-}"
PVE_URL="${PVE_URL:-}"
APACHE_LOG="/var/log/apache2/access.log"
# Apache-Logpfad: Distributionen unterscheiden sich — Debian/Ubuntu/KeyHelp
# nutzen /var/log/apache2/, RHEL/Rocky /var/log/httpd/. Der Installer sucht beim
# ersten Lauf; --apache-log ueberschreibt.
APACHE_LOG=""
TELEGRAF_VERSION="${TELEGRAF_VERSION:-1.40.0}"
# Ueberschreibbar (Tests/Mirror): REPO_RAW=... sh install.sh
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
UNINSTALL=0
CHECK=0
SHOW_VERSION=0
DIAGNOSE=0
FORCE_UPDATE=0
UPDATE_ONLY=0
TIMER_INTERVAL=""
@@ -58,10 +126,10 @@ REMOVE_TIMER=0
while [ $# -gt 0 ]; do
case "$1" in
--name) NODE_NAME="$2"; shift 2 ;;
--name) NODE_NAME="$2"; NAME_GIVEN=1; shift 2 ;;
--collector) COLLECTOR="${2:-}"; shift 2 ;;
--token) TELEGRAF_TOKEN="$2"; shift 2 ;;
--role) ROLE="$2"; shift 2 ;;
--role) ROLE="$2"; ROLE_GIVEN=1; shift 2 ;;
--pve-token) PVE_TOKEN="$2"; shift 2 ;;
--pve-node) PVE_NODE="$2"; shift 2 ;;
--pve-url) PVE_URL="$2"; shift 2 ;;
@@ -69,6 +137,7 @@ while [ $# -gt 0 ]; do
--uninstall) UNINSTALL=1; shift ;;
--check) CHECK=1; shift ;;
--version) SHOW_VERSION=1; shift ;;
--diagnose) DIAGNOSE=1; shift ;;
--force-update) FORCE_UPDATE=1; shift ;;
--update-only) UPDATE_ONLY=1; shift ;;
--install-update-timer)
@@ -84,9 +153,132 @@ while [ $# -gt 0 ]; do
done
log() { printf '\033[1;34m[netbird-telegraf]\033[0m %s\n' "$*"; }
# --------------------------------------------------------------------------
# Bestehende Installation: Werte aus /etc/default/telegraf nachladen
#
# WARUM: Der Auto-Updater liest Token, NODE_NAME und ROLE aus dieser Datei und
# gibt sie dem Unterlauf mit. Ein MANUELLER `--update-only`-Lauf tat das nicht
# und brach an der Token-Pruefung ab ("Kein Token"), obwohl der Token auf der
# Maschine liegt. Die Datei ist die Quelle der Wahrheit fuer eine bereits
# eingerichtete Node — die Kommandozeile ueberschreibt sie nur, wenn sie
# ausdruecklich etwas anderes sagt.
#
# Zweiter, stiller Fehler derselben Ursache: ohne --name fiel NODE_NAME auf den
# Hostnamen zurueck. Auf einem KeyHelp-Host heisst der Host aber nicht wie die
# Node ('x22' vs 'x22.idx.wf-keyhelp') — ein Update-Lauf haette die Node unter
# falschem Label neu registriert. Ebenso haette ein PVE-Knoten ohne --role seine
# Rolle auf 'auto' verloren (kein pvesh-Drop-in mehr).
# --------------------------------------------------------------------------
_rd_default() {
sed -n "s/^$1=//p" /etc/default/telegraf 2>/dev/null | head -1 \
| sed "s/^['\"]//; s/['\"]$//"
}
if [ -r /etc/default/telegraf ]; then
if [ -z "$TELEGRAF_TOKEN" ]; then
TELEGRAF_TOKEN="$(_rd_default TELEGRAF_TOKEN)"
[ -n "$TELEGRAF_TOKEN" ] && log "Token aus /etc/default/telegraf uebernommen."
fi
[ -z "$COLLECTOR" ] && COLLECTOR="$(_rd_default COLLECTOR)"
if [ "$NAME_GIVEN" -eq 0 ]; then
_n="$(_rd_default NODE_NAME)"
if [ -n "$_n" ] && [ "$_n" != "$NODE_NAME" ]; then
log "Node-Name aus /etc/default/telegraf uebernommen: $_n"
NODE_NAME="$_n"
fi
fi
if [ "$ROLE_GIVEN" -eq 0 ]; then
_r="$(_rd_default ROLE)"
if [ -n "$_r" ] && [ "$_r" != "none" ]; then
log "Rolle aus /etc/default/telegraf uebernommen: $_r"
ROLE="$_r"
fi
fi
[ -z "$PVE_NODE" ] && PVE_NODE="$(_rd_default PVE_NODE)"
[ -z "$PVE_URL" ] && PVE_URL="$(_rd_default PVE_URL)"
# APACHE_LOG kommt als gequotete Liste — die Quotes entfernt _rd_default.
fi
# /etc/default/telegraf robust einlesen. Ein direktes "." bricht die ganze
# Installation ab, wenn die Datei einen unquoted Wert mit Leerzeichen oder
# Sonderzeichen enthaelt (alte APACHE_LOG-Zeile mit zwei Pfaden:
# "sh: /etc/default/telegraf: /pfad/zwei.log: Permission denied" — genau das
# passierte auf x22 und blockierte jedes weitere Update).
# Deshalb: nur syntaktisch heile Dateien auswerten, sonst sichern und warnen.
load_defaults() {
[ -r /etc/default/telegraf ] || return 0
if sh -n /etc/default/telegraf >/dev/null 2>&1; then
set -a; . /etc/default/telegraf; set +a
return 0
fi
printf '\033[1;33m[netbird-telegraf] WARNUNG:\033[0m /etc/default/telegraf ist nicht auswertbar — wird neu geschrieben.\n'
_bad="/etc/default/telegraf.unlesbar.$(date +%s)"
cp -a /etc/default/telegraf "$_bad" 2>/dev/null || true
printf '\033[1;33m[netbird-telegraf]\033[0m Sicherung: %s\n' "$_bad"
return 0
}
die() { printf '\033[1;31m[netbird-telegraf] FEHLER:\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Bitte als root ausführen (sudo)."
# --------------------------------------------------------------------------
# --diagnose: prueft in EINEM Aufruf, warum ein Input nichts liefert.
# Prueft genau die Stellen, die schon still versagt haben:
# Rechte des Dienst-Benutzers, leere Dateien, Offset-Zustand, Parser-Fehler.
# --------------------------------------------------------------------------
if [ "$DIAGNOSE" -eq 1 ]; then
printf '=== Telegraf-Installer Diagnose ===\n'
TG="$(command -v telegraf || echo /usr/local/bin/telegraf)"
printf '\n-- Dienst --\n'
if command -v systemctl >/dev/null 2>&1; then
printf 'Status: %s\n' "$(systemctl is-active telegraf 2>/dev/null || echo unbekannt)"
printf 'Benutzer: %s\n' "$(systemctl show telegraf -p User --value 2>/dev/null || echo unbekannt)"
printf 'Gruppen: %s\n' "$(systemctl show telegraf -p SupplementaryGroups --value 2>/dev/null || echo -)"
fi
DBU="$(systemctl show telegraf -p User --value 2>/dev/null || echo telegraf)"
[ -n "$DBU" ] || DBU=telegraf
printf '\n-- Logpfade aus der aktiven Conf --\n'
CF="/etc/telegraf/telegraf.d/apache.conf"
[ -f "$CF" ] || CF="/etc/telegraf/telegraf.conf"
# Beide files-Zeilen (journal + apache_access) lesen, aber entdoppeln —
# sonst erscheint jeder Pfad zweimal in der Ausgabe.
_files="$(sed -n 's/^[[:space:]]*files = \[\(.*\)\].*/\1/p' "$CF" | tr -d '"' | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | sort -u | tr '\n' ' ')"
[ -n "$_files" ] || { printf ' (keine files-Liste gefunden)\n'; }
_n=0; _ok=0; _empty=0; _noread=0
for _f in $_files; do
_n=$((_n+1))
if [ ! -e "$_f" ]; then printf ' FEHLT %s\n' "$_f"; continue; fi
_size="$(stat -c %s "$_f" 2>/dev/null || echo 0)"
if [ "$_size" -eq 0 ]; then printf ' LEER (0 B) %s\n' "$_f"; _empty=$((_empty+1)); continue; fi
# Lesbarkeit aus Sicht des Dienst-Benutzers — das war die stille Fehlerquelle.
if [ "$(id -u)" -eq 0 ] && command -v su >/dev/null 2>&1 && id "$DBU" >/dev/null 2>&1; then
# Dateiname fuer die Shell quoten (Pfade koennen Sonderzeichen haben)
_q="$(printf '%s' "$_f" | sed "s/'/'\\\\''/g")"
if su -s /bin/sh "$DBU" -c "head -c 1 '$_q' >/dev/null 2>&1"; then
printf ' OK %s (%s Bytes)\n' "$_f" "$_size"; _ok=$((_ok+1))
else
printf ' NICHT LESBAR %s (Benutzer %s) ← URSACHE\n' "$_f" "$DBU"; _noread=$((_noread+1))
fi
else
printf ' vorhanden %s (%s Bytes)\n' "$_f" "$_size"
fi
done
printf ' -> %s Pfade, %s lesbar, %s leer, %s nicht lesbar\n' "$_n" "$_ok" "$_empty" "$_noread"
printf '\n-- Parser-Test (ohne Offset-Zwang) --\n'
_out="$("$TG" --config /etc/telegraf/telegraf.conf --config-directory /etc/telegraf/telegraf.d --test --test-wait 2 2>&1)"
printf '%s\n' "$_out" | grep -c 'E!' | sed 's/^/ Fehlerzeilen: /'
printf '%s\n' "$_out" | grep -E 'E!' | head -3 | sed 's/^/ /'
printf '%s\n' "$_out" | grep -cE '^[>] ' | sed 's/^/ erzeugte Messungen: /'
printf '\n-- Offset-Zustand (erste Messung je Datei erst nach neuem Zugriff) --\n'
ls -la /var/lib/telegraf/ 2>/dev/null | tail -5 || printf ' (kein State-Verzeichnis)\n'
printf '\nHinweis: initial_read_offset = "saved-or-end" bedeutet, dass beim ERSTEN\n'
printf 'Lauf nur NEUES gelesen wird. Bestehende Zeilen kommen erst mit neuen\n'
printf 'Zugriffen. Fuer einen sofortigen Test: eine Datei anfordern und warten.\n'
exit 0
fi
# Reine Abfragen brauchen keinen Token und kein root (Timer-Verwaltung schon).
if [ "$SHOW_VERSION" -eq 1 ] || [ "$CHECK" -eq 1 ]; then
LOCAL_V="$(cat /etc/telegraf/CONFIG_VERSION 2>/dev/null || echo 'nicht installiert')"
@@ -149,6 +341,113 @@ elif [ -e /dev/dri/renderD128 ]; then
GPU="intel"
fi
HAS_APACHE=0; { command -v apache2ctl >/dev/null 2>&1 || command -v httpd >/dev/null 2>&1; } && HAS_APACHE=1
if [ "$HAS_APACHE" -eq 1 ] && [ -z "$APACHE_LOG" ]; then
# ---------------------------------------------------------------------------
# Apache-Logpfade ermitteln — MUSTER, nicht expandierte Einzelpfade.
#
# Warum Muster: KeyHelp/Plesk legen pro Domain eigene Logs an, und es kommen
# laufend neue dazu. Eine Conf mit 43 festen Pfaden verliert jede neue Domain,
# bis der Installer erneut laeuft. Ein Glob deckt sie sofort mit ab.
#
# Verifizierte Strukturen (auf x22 real vorgefunden):
# KeyHelp : /home/users/<user>/logs/<domain>/access.log (zweistufig!)
# /var/log/apache2/<vhost>/access.log (z.B. keyhelp/)
# Plesk : /var/www/vhosts/<domain>/logs/access_log
# cPanel : /usr/local/apache/domlogs/<domain>
# klassisch: /var/log/apache2/access.log, /var/log/httpd/access_log
# ---------------------------------------------------------------------------
_pats=""
for _pat in \
"/var/log/apache2/*/access.log" \
"/home/users/*/logs/*/access.log" \
"/home/*/logs/*/access.log" \
"/var/www/vhosts/*/logs/access_log" \
"/usr/local/apache/domlogs/*" \
"/var/log/httpd/*access*.log" \
"/var/log/apache2/*/error.log" \
"/home/users/*/logs/*/error.log" \
"/home/*/logs/*/error.log" \
"/var/log/apache2/error.log" \
"/var/log/httpd/*error*.log"; do
# shellcheck disable=SC2086
if ls $_pat >/dev/null 2>&1; then
_pats="$_pats $_pat"
fi
done
# Klassische Einzeldateien nur, wenn sie NICHT leer sind: auf KeyHelp ist
# /var/log/apache2/access.log vorhanden, aber 0 Bytes — ein Input darauf
# liefert still nichts.
for _p in /var/log/apache2/access.log /var/log/httpd/access_log \
/var/log/apache2/other_vhosts_access.log \
/var/log/apache2/error.log /var/log/httpd/error_log; do
[ -s "$_p" ] && _pats="$_pats $_p"
done
# Nichts gefunden? Dann die CustomLog-Direktive befragen (verlaesslichste
# Quelle bei exotischen Setups) — ${APACHE_LOG_DIR} wird aufgeloest.
if [ -z "$_pats" ]; then
_cl_dir="$(grep -rhoE '^[[:space:]]*CustomLog[[:space:]]+(\S+|"[^"]+")' \
/etc/apache2/ /etc/httpd/ /etc/apache2/sites-enabled/ 2>/dev/null \
| sed -E 's/^[[:space:]]*CustomLog[[:space:]]+//; s/^"//; s/"$//' \
| grep -v '^|' | grep -v '^/dev/' | sort -u)"
for _p in $_cl_dir; do
_p="$(printf '%s' "$_p" | sed "s#\${APACHE_LOG_DIR}#/var/log/apache2#g")"
[ -r "$_p" ] && _pats="$_pats $_p"
done
fi
# Letzter Ausweg: breiter Scan der ueblichen Wurzelverzeichnisse.
if [ -z "$_pats" ]; then
_pats="$(find /var/log/apache2 /var/log/httpd /home -maxdepth 4 \
\( -name 'access.log' -o -name 'access_log' \
-o -name 'error.log' -o -name 'error_log' \) 2>/dev/null \
| head -200 | tr '\n' ' ')"
fi
[ -n "$_pats" ] && APACHE_LOG="$(printf '%s' "$_pats" | sed 's/^ *//; s/ *$//')"
[ -n "$APACHE_LOG" ] || APACHE_LOG="/var/log/apache2/access.log"
fi
# APACHE_LOG kann mehrere, leerzeichengetrennte Pfade enthalten -> als
# TOML-Liste rendern. Achtung: Pfade mit Leerzeichen sind in Apache-Logs
# ungewoehnlich; wir quoten jeden Eintrag einzeln.
# APACHE_FILES IMMER berechnen, NICHT nur bei erkanntem Apache.
# Realer Fehler: bei ROLE=apache ohne Apache-Erkennung (z. B. httpd auf RHEL,
# oder Apache wurde entfernt) blieb die Variable leer. Die Conf rendert dann zu
# "files = " — ungueltiges TOML, Telegraf startet nicht mehr, ALLE Metriken aus.
# Der Guard war die Ursache; die Berechnung selbst ist harmlos.
APACHE_FILES=""
if [ "$HAS_APACHE" -eq 1 ] || [ "$ROLE" = "apache" ]; then
# set -f: Pfadnamen-Expansion abschalten! Sonst ersetzt die Shell die
# Glob-Muster durch die aktuell vorhandenen Dateien — die Conf enthielte
# feste Einzelpfade und jede NEUE Domain fiele heraus, bis der Installer
# erneut laeuft. Wir wollen die Muster in der Conf, Telegraf loest sie bei
# jedem Lauf selbst auf.
# TRENNUNG access/error — wichtig fuer die Metriken:
# Der Grok-Parser versteht nur das ACCESS-Format
# IP ident auth [HTTPDATE] "VERB REQ" CODE BYTES
# Eine Error-Zeile sieht anders aus:
# [Mon Sep 14 00:10:01.123456 2026] [core:error] [pid 9] AH00124: ...
# Laeuft sie durch den Access-Parser, entstehen keine Metriken (Muster passt
# nicht) — schlimmer: der Aggregator zaehlt sie in Gruppen mit, die es nicht
# gibt. Deshalb bekommt der Access-Input nur Access-Pfade, und die Error-Logs
# gehen ueber einen eigenen Input roh nach Loki.
set -f
for _p in $APACHE_LOG; do
case "$_p" in
*error*|*Error*)
APACHE_ERROR_FILES="${APACHE_ERROR_FILES:+$APACHE_ERROR_FILES, }\"$_p\"" ;;
*)
APACHE_FILES="${APACHE_FILES:+$APACHE_FILES, }\"$_p\"" ;;
esac
done
set +f
[ -n "$APACHE_FILES" ] || APACHE_FILES='"/var/log/apache2/access.log"'
APACHE_ERROR_FILES="[${APACHE_ERROR_FILES:-\"/var/log/apache2/error.log\"}]"
export APACHE_ERROR_FILES
APACHE_FILES="[$APACHE_FILES]"
fi
# Sicherheitsnetz: die Variable MUSS nicht-leer sein, bevor apache.conf gerendert
# wird. Ein leerer Wert erzeugt ungueltiges TOML und kostet die ganze Node.
[ -n "${APACHE_FILES:-}" ] || APACHE_FILES='["/var/log/apache2/access.log"]'
export APACHE_FILES
if [ "$ROLE" = "auto" ]; then
ROLE="none"
[ "$IS_PVE" -eq 1 ] && ROLE="proxmox"
@@ -186,13 +485,22 @@ fi
# Installation
# --------------------------------------------------------------------------
install_apt() {
command -v gpg >/dev/null || { apt-get update -qq; apt-get install -y -qq gnupg >/dev/null; }
command -v curl >/dev/null || apt-get install -y -qq curl >/dev/null
# ZWEITE SCHUTZSCHICHT gegen den stdin-Fraß:
# `needrestart` (Ubuntu-Standard) liest stdin und meldet
# "Scanning processes... / Scanning linux images...". Mit NEEDRESTART_MODE=a
# laeuft es nicht-interaktiv, und die apt-Optionen halten dpkg davon ab,
# Prompts zu stellen. Der Re-Exec oben schuetzt die Shell — diese Zeile
# schuetzt zusaetzlich den Installer-Ablauf selbst.
export NEEDRESTART_MODE=a
export DEBIAN_FRONTEND=noninteractive
APT_OPTS="-y -qq -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
command -v gpg >/dev/null || { apt-get update -qq; apt-get install $APT_OPTS gnupg >/dev/null; }
command -v curl >/dev/null || apt-get install $APT_OPTS curl >/dev/null
mkdir -p /etc/apt/keyrings
curl -fsSL https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor --yes -o /etc/apt/keyrings/influxdata-archive.gpg
curl -fsSL -m 30 --connect-timeout 10 https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor --yes -o /etc/apt/keyrings/influxdata-archive.gpg
echo "deb [signed-by=/etc/apt/keyrings/influxdata-archive.gpg] https://repos.influxdata.com/debian stable main" > /etc/apt/sources.list.d/influxdata.list
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq telegraf >/dev/null
apt-get install $APT_OPTS telegraf >/dev/null
}
install_rpm() {
cat > /etc/yum.repos.d/influxdata.repo <<'EOF'
@@ -203,14 +511,20 @@ enabled = 1
gpgcheck = 1
gpgkey = https://repos.influxdata.com/influxdata-archive.key
EOF
if command -v dnf >/dev/null; then dnf install -y -q telegraf; else yum install -y -q telegraf; fi
# dnf/yum lesen ebenfalls stdin (Prompts, Plugin-Rueckfragen) -> hier mit
# -y und ohne Assumeyes-Rueckfrage, damit nichts aus der Pipe greift.
if command -v dnf >/dev/null; then
dnf install -y -q telegraf < /dev/null
else
yum install -y -q telegraf < /dev/null
fi
}
install_static() {
command -v curl >/dev/null || die "curl fehlt."
TB="telegraf-${TELEGRAF_VERSION}_linux_${ARCH}.tar.gz"
log "Statisches Binary $TB …"
TMP="$(mktemp -d)"
curl -fsSL "https://dl.influxdata.com/telegraf/releases/$TB" -o "$TMP/t.tgz"
curl -fsSL -m 120 --connect-timeout 10 "https://dl.influxdata.com/telegraf/releases/$TB" -o "$TMP/t.tgz"
tar -xzf "$TMP/t.tgz" -C "$TMP"
install -m 0755 "$TMP"/telegraf-*/usr/bin/telegraf /usr/local/bin/telegraf
rm -rf "$TMP"
@@ -224,7 +538,13 @@ command_args="--config /etc/telegraf/telegraf.conf --config-directory /etc/teleg
command_user=telegraf
command_background=true
pidfile=/run/telegraf.pid
start_pre() { set -a; [ -r /etc/default/telegraf ] && . /etc/default/telegraf; set +a; }
start_pre() {
# Nur auswerten, wenn die Datei syntaktisch heil ist — sonst startet der
# Dienst gar nicht (unquoted Werte mit Leerzeichen brechen das Sourcing).
if [ -r /etc/default/telegraf ] && sh -n /etc/default/telegraf 2>/dev/null; then
set -a; . /etc/default/telegraf; set +a
fi
}
depend() { need net; }
EOF
chmod +x /etc/init.d/telegraf
@@ -254,16 +574,187 @@ else
case "$OS_ID" in
debian|ubuntu|raspbian|linuxmint|pop) install_apt ;;
rhel|centos|rocky|almalinux|fedora|ol) install_rpm ;;
alpine) apk add --no-cache -q curl ca-certificates; install_static ;;
alpine) apk add --no-cache -q curl ca-certificates < /dev/null; install_static ;;
*) if command -v apt-get >/dev/null; then install_apt
elif command -v dnf >/dev/null || command -v yum >/dev/null; then install_rpm
else install_static; fi ;;
esac
fi
# --------------------------------------------------------------------------
# Log-Leserechte fuer den Dienst-Benutzer
# --------------------------------------------------------------------------
# Zwei verschiedene Rechte-Welten bei Apache-Logs:
# /var/log/apache2/... root:adm 0640 -> usermod -aG adm genuegt
# /home/users/<u>/logs/... <u>:<u> 0640 -> adm hilft NICHT
# KeyHelp/Plesk legen die VHost-Logs in den User-Homes ab (auf x22: 64 von 84
# Pfaden waren fuer 'telegraf' unlesbar). inputs.tail meldet das NICHT — es
# liefert einfach nichts, und der Node sieht "aktiv" aus.
# Loesung: ein ACL fuer den Dienst-Benutzer, rekursiv und vererbbar. Damit
# bleibt der Dienst im eingeschraenkten Konto (kein root noetig) und neue
# Domain-Ordner erben das Recht automatisch.
_acl_fixed=0
grant_apache_log_access() {
[ "$HAS_APACHE" -eq 1 ] || return 0
# Fortschritt melden: ohne diese Zeile sah der Lauf wie ein Haenger aus,
# weil zwischen "Telegraf bereits installiert" und der naechsten Meldung
# ein apt-Aufruf mit Netzwerkzugriff liegt (kann bis zu 3 Minuten dauern).
log "Log-Leserechte: pruefe/ergaenze ACL fuer 'telegraf' (kann etwas dauern) …"
_u="telegraf"
id "$_u" >/dev/null 2>&1 || return 0
if ! command -v setfacl >/dev/null 2>&1; then
# acl-Paket nachziehen, sonst ist die Rechtevergabe nicht moeglich.
# Alle Paketmanager mit </dev/null: sie duerfen nichts aus stdin lesen.
# `timeout` ist PFLICHT: ohne Paketlisten kann apt in eine Rueckfrage laufen
# und unbegrenzt warten — der Installer haengt dann nach der letzten
# Erfolgsmeldung, ohne Fehler. Real passiert (x14): Abbruch per ^C.
if command -v apt-get >/dev/null; then
apt-get update -qq </dev/null >/dev/null 2>&1 || true
timeout 180 env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive \
apt-get install -y -qq acl </dev/null >/dev/null 2>&1 || true
elif command -v dnf >/dev/null; then
timeout 180 dnf install -y -q acl </dev/null >/dev/null 2>&1 || true
elif command -v yum >/dev/null; then
timeout 180 yum install -y -q acl </dev/null >/dev/null 2>&1 || true
elif command -v apk >/dev/null; then
timeout 180 apk add --no-cache -q acl </dev/null >/dev/null 2>&1 || true
fi
fi
if ! command -v setfacl >/dev/null 2>&1; then
log "HINWEIS: setfacl fehlt — Apache-Logs in User-Homes bleiben evtl. unlesbar."
log " Pruefen mit: sh install.sh --diagnose"
return 0
fi
# 1) Glob-Muster aus der aktiven Conf ermitteln.
#
# QUELLE, richtig herum:
# 1. die aktive apache.conf (Folge-Laeufe — dort stehen die Muster)
# 2. sonst APACHE_LOG (in DIESEM Lauf erkannt; beim Erstinstall existiert die
# Conf noch nicht — vorher griff hier ein grober Fallback auf /home/users
# und /var/www/vhosts, also auf Mails und Webinhalte)
_pfadquelle=""
if [ -f /etc/telegraf/telegraf.d/apache.conf ]; then
_pfadquelle="$(sed -n 's/^[[:space:]]*files = \[\(.*\)\].*/\1/p' \
/etc/telegraf/telegraf.d/apache.conf 2>/dev/null | tr -d '"' | tr ',' ' ')"
fi
[ -n "$_pfadquelle" ] || _pfadquelle="${APACHE_LOG:-}"
[ -n "$_pfadquelle" ] || return 0
acl_set() { timeout 30 setfacl -m "u:$1:$2" "$3" 2>/dev/null || true; }
# Unter `set -u` PFLICHT: ohne Initialisierung bricht die Funktion beim ersten
# `_touched="$_touched …"` ab ("parameter not set"), der Installer endet
# kommentarlos mitten in der Rechtevergabe. Real passiert (V1.9.0, Erstlauf).
_touched=""
# 1a) Rechte aus den MUSTERN ableiten.
#
# Eine Wildcard wird per readdir expandiert. Dafuer muss JEDES Verzeichnis auf
# dem Weg zum Treffer AUFLISTBAR sein — also `r-x`, nicht nur `x`. `x` laesst
# Telegraf ein Verzeichnis betreten, aber nicht auflisten.
#
# REALER, STILLER FEHLER (x14): die vorige Fassung gab /home/users nur `--x`.
# Telegraf meldete
# W! [inputs.tail] Directory "/home/users" is not readable but is followed
# by wildcards, make sure you set read permissions or globbing will not
# work!
# und lieferte 0 von 56 Logdateien: Confs geladen, kein Fehler, Gegentest gruen,
# aber es floss nichts — genau die stille Datenluecke, die nicht passieren darf.
#
# `set -f` ist hier PFLICHT. Ohne expandiert der Root-Shell jedes Muster, BEVOR
# die Schleife es sieht; _pattern waere ein konkreter Dateiname, der Wildcard-
# Zweig liefe nie, und die ACL landete nur auf den Dateien — die Glob-Wurzeln
# blieben unlesbar. Real passiert: "ACL auf 2 Datei(en)", 0 Treffer im Dienst.
set -f
for _pattern in $_pfadquelle; do
case "$_pattern" in
*'*'*)
# Wurzel des Musters = alles vor dem ERSTEN Stern.
# ${_pattern%%**} waere leer (Muster "**" = beliebiger String), deshalb
# die Zeichenklasse: ${_pattern%%[*]*} schneidet ab dem ersten Stern.
_pre="${_pattern%%[*]*}"
_root="${_pre%/*}"
[ -n "$_root" ] || _root="/"
# Hier wird expandiert -> die Wurzel muss auflistbar sein.
[ -d "$_root" ] && acl_set "$_u" r-x "$_root"
# Konkrete Treffer: jetzt mit Expansion.
set +f
for _hit in $_pattern; do
[ -f "$_hit" ] || continue
acl_set "$_u" r "$_hit"
_touched="$_touched $_hit"
# Jede Ebene zwischen Wurzel und Datei: auflistbar UND betretbar.
_d="$(dirname "$_hit")"
while [ -n "$_d" ] && [ "$_d" != "/" ] && [ "$_d" != "$_root" ]; do
acl_set "$_u" r-x "$_d"
_d="$(dirname "$_d")"
done
done
set -f
;;
*)
if [ -f "$_pattern" ]; then acl_set "$_u" r "$_pattern"; _touched="$_touched $_pattern"; fi
;;
esac
done
# 2) Gegentest: die GLOB-AUFLOESUNG pruefen, nicht die Datei-Lesbarkeit.
#
# REALER FEHLER: der alte Test las jede gefundene Datei per `head -c 1` als
# telegraf. Das KONNTE nicht fehlschlagen — die Datei war ja vom Root-Shell
# expandiert worden; eine ACL lag auf der Datei, nur die Glob-Wurzel blieb
# unlesbar. Telegraf bekam 0 Treffer, die Meldung sagte "alle N lesen".
# Hier wird deshalb GENAU das gemessen, was Telegraf tut: dasselbe Muster im
# Dienstkontext expandieren und die Trefferzahl MIT der Wurzelzahl vergleichen.
_tested=0; _fail=0; _firstfail=""
for _pattern in $_pfadquelle; do
case "$_pattern" in *'*'*) : ;; *) continue ;; esac
# Trefferzahl als root: dafür MUSS expandiert werden.
set +f
_root_cnt=0
for _c in $_pattern; do [ -f "$_c" ] && _root_cnt=$((_root_cnt+1)); done
set -f
# Trefferzahl als Dienstbenutzer: das Muster unquoted uebergeben, damit die
# Ziel-Shell selbst globbt.
_cnt="$(su -s /bin/sh "$_u" -c '
n=0
for _c in '"$_pattern"'; do [ -f "$_c" ] && n=$((n+1)); done
printf "%s" "$n"' 2>/dev/null)"
_cnt="${_cnt:-0}"
_tested=$((_tested+1))
# Blind muss ausgeschlossen sein: 0 von 0 waere kein Beweis.
if [ "$_root_cnt" -eq 0 ]; then
_fail=$((_fail+1))
[ -n "$_firstfail" ] || _firstfail="$_pattern (kein Treffer als root — Muster pruefen)"
elif [ "$_cnt" -lt "$_root_cnt" ]; then
_fail=$((_fail+1))
[ -n "$_firstfail" ] || _firstfail="$_pattern (Dienst sieht $_cnt von $_root_cnt)"
fi
done
set +f
_n=0
[ -n "$_touched" ] && _n=$(printf '%s\n' $_touched | grep -c .)
[ "$_n" -gt 0 ] && log "Log-Leserechte gesetzt (ACL fuer '$_u') auf $_n Datei(en)."
# Fuer den Update-Only-Bericht: melden, ob in DIESEM Lauf Rechte gesetzt wurden.
_acl_fixed="${_n:-0}"
if [ "$_tested" -eq 0 ]; then
log "HINWEIS: kein Glob-Muster gefunden — Gegentest nicht moeglich."
elif [ "$_fail" -eq 0 ]; then
log "Gegentest: '$_u' loest alle $_tested Log-Muster vollstaendig auf."
else
log "WARNUNG: $_fail von $_tested Log-Mustern sind fuer '$_u' NICHT aufloesbar."
log " Beispiel: $_firstfail"
log " Pruefen mit: sh install.sh --diagnose"
fi
}
# --------------------------------------------------------------------------
# Konfiguration
# --------------------------------------------------------------------------
# Fortschritt melden: ab hier folgen mehrere Netzwerk-Downloads (base.conf,
# profil-conf, Hilfsscripts). Ohne diese Zeile sieht ein langsamer Download
# wie ein Haenger nach der letzten Erfolgsmeldung aus.
log "Schreibe Konfiguration (laedt Vorlagen vom Repo) …"
# Proxmox-Parameter VOR dem Schreiben der Konfiguration ermitteln
if [ "$ROLE" = "proxmox" ]; then
[ -n "$PVE_NODE" ] || PVE_NODE="$(pvesh get /nodes --output-format json 2>/dev/null | sed -n 's/.*"node":"\([^"]*\)".*/\1/p' | head -1)"
@@ -276,6 +767,11 @@ mkdir -p /etc/telegraf/telegraf.d /etc/default
umask 077
cat > /etc/default/telegraf <<EOF
NODE_NAME=$NODE_NAME
# Die InfluxData-Unit nutzt \$TELEGRAF_OPTS in ExecStart. Ohne diesen Wert
# warnt systemd bei jedem Start "Referenced but unset environment variable".
# Explizit leer setzen = Warnung weg, Verhalten unveraendert.
TELEGRAF_OPTS=
# ROLE/COLLECTOR/PVE_* braucht der Auto-Updater, um das Profil zu erhalten
# (sonst verliert ein PVE-Knoten beim Update seine Rolle).
ROLE=$ROLE
@@ -286,11 +782,30 @@ TELEGRAF_TOKEN=$TELEGRAF_TOKEN
PVE_TOKEN=$PVE_TOKEN
PVE_NODE=$PVE_NODE
PVE_URL=$PVE_URL
APACHE_LOG=$APACHE_LOG
# WICHTIG: Diese Datei wird per ". /etc/default/telegraf" GESOURCT. Werte ohne
# Anfuehrungszeichen brechen bei Leerzeichen oder Sonderzeichen: bei mehreren
# Logpfaden versucht die Shell, den zweiten Pfad als Befehl auszufuehren
# ("...access.log: Permission denied"). Eckige Klammern (TOML-Liste) sind im
# Sourcing sogar Syntaxmuell. Deshalb alles quoten — die Werte werden in der
# Config nur als String gebraucht, nicht als Liste.
APACHE_LOG='$APACHE_LOG'
EOF
umask 022
fetch() { curl -fsSL "$REPO_RAW/$1" -o "$2" || die "Download $1 fehlgeschlagen."; }
# Jeder Download MIT Timeout: ohne -m wartet curl unbegrenzt, wenn das Netz
# haengt (NetBird-Tunnel, DNS). Der Installer bliebe dann nach der letzten
# Erfolgsmeldung stehen — ohne Fehler, ohne Ende. Genau das passierte auf x14.
fetch() {
_tries=3
while [ "$_tries" -gt 0 ]; do
if curl -fsSL -m 30 --connect-timeout 10 "$REPO_RAW/$1" -o "$2" 2>/dev/null; then
return 0
fi
_tries=$((_tries - 1))
[ "$_tries" -gt 0 ] && sleep 2
done
die "Download $1 fehlgeschlagen nach 3 Versuchen ($REPO_RAW/$1)."
}
# Ersatz fuer envsubst, falls gettext fehlt (Minimal-Container, schlanke
# Alpine-/Rocky-Installationen). Ersetzt nur die uebergebenen Variablennamen —
@@ -310,11 +825,19 @@ subst_vars() { # $1 = Quelldatei, $2 = Zieldatei, rest = Variablennamen
# Holt eine Conf und ersetzt ${...}-Platzhalter aus /etc/default/telegraf.
fetch_conf() {
fetch "$1" "$2.tmp"
set -a; [ -r /etc/default/telegraf ] && . /etc/default/telegraf; set +a
load_defaults
if command -v envsubst >/dev/null; then
envsubst '${NODE_NAME} ${PROM_URL} ${LOKI_URL} ${TELEGRAF_TOKEN} ${PVE_TOKEN} ${PVE_NODE} ${PVE_URL} ${APACHE_LOG}' < "$2.tmp" > "$2"
# PFLICHT: Redirection. Ohne "< $2.tmp" liest envsubst STDIN.
#
# Realer Ausfall (x14): hier fehlte die Redirection. `envsubst '${…}'` ohne
# Eingabeumleitung wartet auf stdin — bei `sh install.sh` vom Terminal ist
# das die Konsole, der Installer blieb also NACH "Schreibe Konfiguration …"
# stehen und wartete auf Eingabe. Der Trace endete exakt hier.
# Bei `curl … | sh` waere es noch schlimmer: envsubst wuerde den REST des
# Skripts als Eingabe verbrauchen.
envsubst '${NODE_NAME} ${PROM_URL} ${LOKI_URL} ${TELEGRAF_TOKEN} ${PVE_TOKEN} ${PVE_NODE} ${PVE_URL} ${APACHE_LOG} ${APACHE_FILES} ${APACHE_ERROR_FILES}' < "$2.tmp" > "$2"
else
subst_vars "$2.tmp" "$2" NODE_NAME PROM_URL LOKI_URL TELEGRAF_TOKEN PVE_TOKEN PVE_NODE PVE_URL APACHE_LOG
subst_vars "$2.tmp" "$2" NODE_NAME PROM_URL LOKI_URL TELEGRAF_TOKEN PVE_TOKEN PVE_NODE PVE_URL APACHE_LOG APACHE_FILES APACHE_ERROR_FILES
fi
if grep -q '\${' "$2"; then
log "WARNUNG: $2 enthaelt noch unaufgeloeste Platzhalter:"
@@ -323,7 +846,12 @@ fetch_conf() {
rm -f "$2.tmp"
}
fetch_conf telegraf/base.conf /etc/telegraf/telegraf.conf
rm -f /etc/telegraf/telegraf.d/*.conf
# Nur Confs entfernen, die DIESER Installer verwaltet — nicht pauschal alles.
# Sonst reisst ein Auto-Update fremde oder manuell ergaenzte Confs mit, die
# jemand bewusst nach /etc/telegraf/telegraf.d/ gelegt hat.
for _c in docker proxmox apache gpu journal nodeinfo syslog; do
rm -f "/etc/telegraf/telegraf.d/$_c.conf"
done
[ "$HAS_DOCKER" -eq 1 ] && { fetch telegraf/docker.conf /etc/telegraf/telegraf.d/docker.conf; usermod -aG docker telegraf 2>/dev/null || true; }
case "$ROLE" in
proxmox) : # pvesh-Weg braucht keinen Token (siehe unten)
@@ -338,11 +866,17 @@ case "$ROLE" in
commands = ["/etc/telegraf/proxmox-ve.sh"]
EOF
log "Proxmox: pvesh-Input aktiv (VM/CT-Metriken, kein API-Token noetig)." ;;
apache) fetch_conf telegraf/apache.conf /etc/telegraf/telegraf.d/apache.conf; usermod -aG adm telegraf 2>/dev/null || true ;;
apache) fetch_conf telegraf/apache.conf /etc/telegraf/telegraf.d/apache.conf
usermod -aG adm telegraf 2>/dev/null || true
grant_apache_log_access ;;
docker|none) ;;
*) die "Unbekannte Rolle: $ROLE" ;;
esac
[ "$HAS_APACHE" -eq 1 ] && [ "$ROLE" != "apache" ] && { fetch_conf telegraf/apache.conf /etc/telegraf/telegraf.d/apache.conf; usermod -aG adm telegraf 2>/dev/null || true; }
[ "$HAS_APACHE" -eq 1 ] && [ "$ROLE" != "apache" ] && {
fetch_conf telegraf/apache.conf /etc/telegraf/telegraf.d/apache.conf
usermod -aG adm telegraf 2>/dev/null || true
grant_apache_log_access
}
# --- System-Logs (journald) → Loki — nur auf systemd-Systemen ---
if command -v journalctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
@@ -436,19 +970,42 @@ if [ "$FORCE_UPDATE" -eq 1 ]; then
log "Force-Update: Confs und Scripts werden neu geschrieben."
fi
# --------------------------------------------------------------------------
# Konfig testen
# --------------------------------------------------------------------------
# WICHTIG: NICHT mit --input-filter pruefen! Telegraf validiert dann nur die
# gefilterten Plugins und ueberspringt die uebrigen Konfigdateien. Ein Fehler in
# telegraf.d/apache.conf (z.B. ein ungueltiges Feld) lief damit durch, der
# Installer meldete "OK", und erst der Dienststart scheiterte:
# Job for telegraf.service failed
# Ohne Filter werden ALLE Dateien geparst — genau wie beim Dienststart. Das ist
# der einzige Test, der den Startfehler wirklich verhindert.
TG="$(command -v telegraf || echo /usr/local/bin/telegraf)"
set -a; . /etc/default/telegraf; set +a
if ! "$TG" --config /etc/telegraf/telegraf.conf --config-directory /etc/telegraf/telegraf.d --test --input-filter cpu:mem >/dev/null 2>&1; then
load_defaults
conf_test() {
_out="$("$TG" --config /etc/telegraf/telegraf.conf \
--config-directory /etc/telegraf/telegraf.d --test --test-wait 1 2>&1)"
_rc=$?
# Nur echte Konfigurationsfehler zaehlen. Ein fehlendes Input-Binary
# (nvidia-smi) erscheint erst beim Start als E!, nicht beim Parsen — und
# Sammel-Warnungen wie die DeprecationNotice sind harmlos.
if [ "$_rc" -ne 0 ] || printf '%s' "$_out" | grep -q '^.*E!.*loading config file'; then
printf '%s\n' "$_out" | grep -E 'E!' | head -5 >&2
return 1
fi
return 0
}
if ! conf_test; then
# Bei Force-/Auto-Update zuerst zurueckrollen, dann erst aufgeben.
if [ "$FORCE_UPDATE" -eq 1 ] || [ "$UPDATE_ONLY" -eq 1 ]; then
log "Konfiguration ungültig — Rollback auf die letzte Sicherung."
rollback_conf || true
"$TG" --config /etc/telegraf/telegraf.conf --config-directory /etc/telegraf/telegraf.d --test --input-filter cpu:mem >/dev/null 2>&1 \
|| die "Konfiguration auch nach Rollback ungültig — telegraf --test prüfen."
conf_test || die "Konfiguration auch nach Rollback ungültig — telegraf --test prüfen."
log "Rollback erfolgreich, alte Konfiguration aktiv."
else
die "Konfiguration ungültig — telegraf --test prüfen."
die "Konfiguration ungültig — siehe Meldung oben. Einzelne Datei pruefen mit: telegraf --config /etc/telegraf/telegraf.conf --config-directory /etc/telegraf/telegraf.d --test"
fi
fi
@@ -478,7 +1035,12 @@ if [ "$UPDATE_ONLY" -eq 1 ] && [ -n "$SNAPSHOT_BEFORE" ]; then
if cmp -s "$SNAPSHOT_BEFORE" "$SNAPSHOT_AFTER"; then CHANGED=0; fi
rm -f "$SNAPSHOT_BEFORE" "$SNAPSHOT_AFTER"
if [ "$CHANGED" -eq 0 ]; then
# Ehrlich bleiben: die Rechtevergabe ist bereits gelaufen (siehe oben) und
# wirkt ohne Neustart — Telegraf findet neu lesbare Dateien beim naechsten
# Glob-Durchlauf. Ein Lauf, der nur die Rechte reparierte, darf nicht wie
# "nichts passiert" aussehen: genau daran war x14 schwer zu deuten.
log "Update-Only: keine Aenderung an Confs/Scripts — kein Neustart noetig."
[ "${_acl_fixed:-0}" -gt 0 ] && log " (Log-Leserechte wurden in diesem Lauf gesetzt — wirkt sofort, kein Neustart erforderlich.)"
# Versionsmarker TROTZDEM auf den Repo-Stand ziehen: sonst meldet der
# naechste Timer-Lauf erneut ein Update und laeuft endlos im Kreis.
_RV="$(curl -fsS -m 15 "$REPO_RAW/VERSION" 2>/dev/null | tr -d ' \r\n' || true)"
+380
View File
@@ -0,0 +1,380 @@
#!/usr/bin/env python3
"""
apache-log-archive.py — Alte Apache-Logs parsen und als Prometheus-Archiv ablegen.
ABLAUF
1. Logs einlesen (auch rotierte .gz), Access- UND Error-Logs.
2. Pro Stunde/Domain/Status zu Metriken aggregieren.
3. OpenMetrics-Dateien schreiben (Prometheus-Textformat mit Zeitstempeln).
4. `promtool tsdb create-blocks-from openmetrics` erzeugt daraus TSDB-Blöcke.
5. Ergebnis als tar.gz bereitstellen — zur Uebergabe an den zentralen Server.
WARUM AGGREGIERT UND NICHT PRO REQUEST
Prometheus ist eine TSDB, kein Logstore. Eine Serie pro Request waere bei
Millionen Zeilen sofort toedlich (Kardinalitaet). Aggregiert auf
Stunde/Domain/Status bleiben es wenige tausend Serien — und beantwortet genau
die Fragen, fuer die man alte Logs anschaut: Wie viele Requests, welche
Fehlerquote, welcher Traffic, zu welcher Zeit.
Rohzeilen gehoeren in ein Logarchiv (Loki/tar.gz), nicht in die TSDB. Dieses
Skript produziert beides: Metriken fuer Prometheus UND ein tar.gz der Rohlogs.
ERZEUGTE METRIKEN (Name : Labels)
apache_archive_requests_total : domain, method, status, status_class
apache_archive_response_bytes_total : domain
apache_archive_unique_clients : domain (geschaetzt, pro Stunde)
apache_archive_errors_total : domain, level
apache_archive_log_lines_total : domain, kind (access|error|unparsed)
Alle tragen zusaetzlich label "archived=1" und "instance=<node>", damit sie
im Dashboard von Live-Daten unterscheidbar sind.
AUFRUF (auf dem Quellhost, als root)
python3 apache-log-archive.py --node x22.idx.wf-keyhelp
python3 apache-log-archive.py --node x22 --out /root/apache-archive
Danach die .tar.gz auf den imac bringen und dort einspielen — AUSSCHLIESSLICH
ueber prom-archive-import.sh in die getrennte ARCHIV-Instanz (:9091).
NIEMALS die Bloecke von Hand in die Produktions-TSDB entpacken: ein Block mit
Zeitstempeln ausserhalb des Live-Fensters verschiebt dort die Zeitreferenz und
laesst jeden echten Sample als "too old" verwerfen (real passiert am 14.09.2026,
13 Nodes verloren ihre Metriken).
"""
import argparse
import glob
import gzip
import os
import re
import subprocess
import sys
import tarfile
import tempfile
from collections import defaultdict
from datetime import datetime, timezone
PATTERNS = [
"/var/log/apache2/*access*.log", "/var/log/apache2/*access*.log.*",
"/var/log/apache2/*error*.log", "/var/log/apache2/*error*.log.*",
"/home/users/*/logs/*/access.log", "/home/users/*/logs/*/access.log.*",
"/home/users/*/logs/*/error.log", "/home/users/*/logs/*/error.log.*",
"/var/www/vhosts/*/logs/access_log*", "/var/www/vhosts/*/logs/error_log*",
]
RE_LINE = re.compile(
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<req>[^"]*)" (?P<status>\d{3}|-) (?P<bytes>\d+|-)'
)
RE_ERR = re.compile(r'^\[(?P<ts>[^\]]+)\] \[(?P<mod>[^\]:]+):(?P<lvl>\w+)\]')
TS_FORMATS = [
# access.log (Apache Access, MIT Zeitzone)
"%d/%b/%Y:%H:%M:%S %z",
# ERROR-LOGS OHNE ZEITZONE — der Apache-Standard in error.log:
# [Mon Sep 14 00:10:01.123456 2026] [core:error] ...
# [Mon Sep 14 00:10:01 2026] [php:warn] ...
"%a %b %d %H:%M:%S.%f %Y",
"%a %b %d %H:%M:%S %Y",
# KeyHelp/Debian schreiben error.log AUCH im Access-Format, aber OHNE
# Zeitzone: [01/Sep/2026:10:00:05] [core:error] ...
# Fehlte hier bisher -> parse_ts() gab None -> hour="unknown" -> die
# Error-Metrik wurde STILL verworfen (kein Fehler, kein Log, das Panel
# blieb leer). Real gemessen: error.log-Zeilen landeten nur in
# log_lines_total, apache_archive_errors_total fehlte komplett.
"%d/%b/%Y:%H:%M:%S",
]
# Metriknamen, die das Skript erzeugt — auch fuer den Import-Loader relevant.
METRICS = [
"apache_archive_requests_total",
"apache_archive_response_bytes_total",
"apache_archive_unique_clients",
"apache_archive_errors_total",
"apache_archive_log_lines_total",
]
def parse_ts(raw):
for fmt in TS_FORMATS:
try:
dt = datetime.strptime(raw.strip(), fmt)
except ValueError:
continue
if dt.tzinfo is None:
dt = dt.astimezone()
return dt.astimezone(timezone.utc)
return None
def status_class(code):
try:
c = int(code)
except (TypeError, ValueError):
return "unknown"
return f"{c // 100}xx"
def esc(v):
"""Label-Wert fuer OpenMetrics escapen (Backslash, Anfuehrungszeichen, LF)."""
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def collect(patterns):
"""Alle Logdateien finden und parsen. Liefert Zaehler und Rohdateiliste."""
files = sorted({f for pat in patterns for f in glob.glob(pat)})
# requests[(hour, domain, method, status)] = count
requests = defaultdict(int)
bytes_by_domain = defaultdict(int)
bytes_by_hour = defaultdict(int) # (hour, domain) -> Bytes
clients = defaultdict(set) # (hour, domain) -> {ip}
errors = defaultdict(int) # (hour, domain, level) -> count
lines = defaultdict(int) # (domain, kind) -> count
# Zeitstempel, die KEIN Format getroffen haben. Frueher still verworfen —
# dadurch fehlte die Error-Metrik komplett, ohne Fehlermeldung.
unparsed_ts = defaultdict(int) # (domain, roh) -> count
for path in files:
domain = os.path.basename(os.path.dirname(path)) or os.path.basename(path)
try:
fh = (gzip.open(path, "rt", errors="replace") if path.endswith(".gz")
else open(path, "r", errors="replace"))
except OSError:
continue
with fh:
for line in fh:
m = RE_LINE.match(line)
if m:
lines[(domain, "access")] += 1
ts = parse_ts(m.group("ts"))
req = m.group("req") or ""
method = req.split(" ", 1)[0] if " " in req else "UNKNOWN"
status = m.group("status") or "unknown"
hour = ts.strftime("%Y-%m-%dT%H:00:00Z") if ts else "unknown"
requests[(hour, domain, method, status)] += 1
try:
nb = int(m.group("bytes") or 0)
bytes_by_domain[domain] += nb
if ts:
bytes_by_hour[(hour, domain)] += nb
except ValueError:
pass
if ts:
clients[(hour, domain)].add(m.group("ip"))
continue
m = RE_ERR.match(line)
if m:
lines[(domain, "error")] += 1
ts = parse_ts(m.group("ts"))
hour = ts.strftime("%Y-%m-%dT%H:00:00Z") if ts else "unknown"
if ts:
errors[(hour, domain, m.group("lvl").lower())] += 1
else:
# NICHT still verwerfen: ein unparsbarer Zeitstempel hat
# vorher die ganze Error-Metrik gekostet, ohne jede Spur.
unparsed_ts[(domain, m.group("ts")[:40])] += 1
continue
lines[(domain, "unparsed")] += 1
return files, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines, unparsed_ts
def write_openmetrics(path, node, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines):
"""OpenMetrics-Textdatei schreiben. Zeitstempel in SEKUNDEN.
ACHTUNG: OpenMetrics will Sekunden (float). Nicht Millisekunden —
Prometheus multipliziert beim Import mit 1000 und legt die Bloecke
sonst ins Jahr 58.662, wo sie verworfen werden.
"""
base_labels = f'archived="1",instance="{esc(node)}"'
n = 0
_max_ts = 0 # groesster geschriebener Zeitstempel (Einheiten-Waechter)
with open(path, "w") as fh:
# EINHEITEN — hier lag ein realer Fehler:
# OpenMetrics-Zeitstempel sind SEKUNDEN (float). Prometheus wandelt beim
# Import ms = s * 1000. Wer hier Millisekunden schreibt, bekommt Bloecke
# im Jahr 58.662 — sie werden als "too far into the future" verworfen
# und die Abfrage liefert stillschweigend nichts. Kein * 1000.
fh.write("# TYPE apache_archive_requests_total counter\n")
fh.write("# TYPE apache_archive_response_bytes_total counter\n")
fh.write("# TYPE apache_archive_unique_clients gauge\n")
fh.write("# TYPE apache_archive_errors_total counter\n")
fh.write("# TYPE apache_archive_log_lines_total counter\n")
for (hour, domain, method, status), cnt in sorted(requests.items()):
if hour == "unknown":
continue
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
.replace(tzinfo=timezone.utc).timestamp())
labels = (f'{base_labels},domain="{esc(domain)}",method="{esc(method)}",'
f'status="{esc(status)}",status_class="{esc(status_class(status))}"')
fh.write(f"apache_archive_requests_total{{{labels}}} {cnt} {ms}\n")
_max_ts = max(_max_ts, ms)
n += 1
# Bytes pro Stunde: das ergibt die Traffic-Zeitreihe im Archiv.
for (hour, domain), total in sorted(bytes_by_hour.items()):
if hour == "unknown":
continue
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
.replace(tzinfo=timezone.utc).timestamp())
labels = f'{base_labels},domain="{esc(domain)}"'
fh.write(f"apache_archive_response_bytes_total{{{labels}}} {total} {ms}\n")
_max_ts = max(_max_ts, ms)
n += 1
for (hour, domain), ips in sorted(clients.items()):
try:
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
.replace(tzinfo=timezone.utc).timestamp())
except ValueError:
continue
labels = f'{base_labels},domain="{esc(domain)}"'
fh.write(f"apache_archive_unique_clients{{{labels}}} {len(ips)} {ms}\n")
_max_ts = max(_max_ts, ms)
n += 1
for (hour, domain, level), cnt in sorted(errors.items()):
if hour == "unknown":
continue
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
.replace(tzinfo=timezone.utc).timestamp())
labels = f'{base_labels},domain="{esc(domain)}",level="{esc(level)}"'
fh.write(f"apache_archive_errors_total{{{labels}}} {cnt} {ms}\n")
_max_ts = max(_max_ts, ms)
n += 1
# Zeilenzahlen sind Zustandswerte ohne eigenen Zeitbezug. Sie werden auf
# die LETZTE Log-Stunde gestempelt, nicht auf "jetzt" — sonst haengt ein
# Archiv-Eintrag am Abruftag und die Historie wirkt verschoben.
_hours = [h for (h, _, _, _) in requests if h != "unknown"]
if _hours:
stamp_s = int(datetime.strptime(max(_hours), "%Y-%m-%dT%H:00:00Z")
.replace(tzinfo=timezone.utc).timestamp())
else:
stamp_s = int(datetime.now(timezone.utc).timestamp())
for (domain, kind), cnt in sorted(lines.items()):
labels = f'{base_labels},domain="{esc(domain)}",kind="{esc(kind)}"'
fh.write(f"apache_archive_log_lines_total{{{labels}}} {cnt} {stamp_s}\n")
_max_ts = max(_max_ts, stamp_s)
n += 1
# EINHEITEN-WAECHTER: Der groesste Zeitstempel muss ein plausibles
# Sekunden-Datum ergeben. Faengt 1000x-Fehler (ms statt s) sofort ab,
# statt sie still in einen nutzlosen Block zu schreiben.
if _max_ts > 4102444800: # nach dem Jahr 2100 -> kein Sekundenwert
raise SystemExit(
f"FEHLER: Zeitstempel {_max_ts} liegt nach dem Jahr 2100.\n"
f" OpenMetrics erwartet SEKUNDEN, nicht Millisekunden.\n"
f" Pruefe die *1000-Multiplikationen in diesem Skript.\n"
f" Ohne Korrektur entstehen Bloecke, die Prometheus verwirft.")
# PFLICHT: OpenMetrics verlangt "# EOF" als letzte Zeile. Ohne das
# lehnt promtool ab: "data does not end with # EOF".
fh.write("# EOF\n")
return n
def main():
ap = argparse.ArgumentParser(description="Apache-Logs als Prometheus-Archiv aufbereiten")
ap.add_argument("--node", required=True, help="Node-Name fuer das instance-Label")
ap.add_argument("--out", default="/root/apache-archive", help="Ausgabeverzeichnis")
ap.add_argument("--include-raw", action="store_true",
help="Rohlogs mit ins tar.gz packen (empfohlen)")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
print(f"[1/5] Logs einlesen …")
(files, requests, bytes_by_domain, bytes_by_hour,
clients, errors, lines, unparsed_ts) = collect(PATTERNS)
if not files:
print("Keine Logdateien gefunden. Muster:", ", ".join(PATTERNS[:4]), file=sys.stderr)
return 1
total_lines = sum(lines.values())
print(f" {len(files)} Dateien, {total_lines:,} Zeilen")
print(f" {sum(requests.values()):,} Zugriffe, {sum(errors.values()):,} Fehlereintraege")
# Unparsbare Zeitstempel LAUT melden. Sie kosteten frueher die ganze
# Error-Metrik, ohne dass irgendwo etwas stand.
if unparsed_ts:
n = sum(unparsed_ts.values())
print(f" WARNUNG: {n:,} Eintraege mit unlesbarem Zeitstempel — "
f"diese fehlen in den Zeitreihen (nicht in den Rohdaten).", file=sys.stderr)
for (dom, raw), cnt in sorted(unparsed_ts.items(), key=lambda x: -x[1])[:5]:
print(f" {cnt:>6}x domain={dom} Beispiel: {raw}", file=sys.stderr)
print(" Ein fehlendes Format in TS_FORMATS ergaenzen, dann erneut laufen.",
file=sys.stderr)
print(f"[2/5] OpenMetrics schreiben …")
om = os.path.join(args.out, "apache-archive.openmetrics")
n = write_openmetrics(om, args.node, requests, bytes_by_domain, bytes_by_hour,
clients, errors, lines)
size = os.path.getsize(om)
print(f" {n:,} Datenpunkte, {size/1024:.1f} KiB")
# promtool suchen — liegt beim prometheus-Container bzw. als Binary vor.
promtool = None
for cand in ("promtool", "/usr/local/bin/promtool", "/usr/bin/promtool"):
try:
subprocess.run([cand, "--version"], capture_output=True, check=True)
promtool = cand
break
except (OSError, subprocess.CalledProcessError):
continue
if promtool is None:
print(f"[3/5] promtool nicht gefunden — openmetrics-Datei bleibt liegen.")
print(f" Der zentrale Server kann sie direkt verarbeiten:")
print(f" promtool tsdb create-blocks-from openmetrics {om} <blockdir>")
blocks_dir = None
else:
print(f"[3/5] TSDB-Bloecke erzeugen (promtool) …")
blocks_dir = os.path.join(args.out, "blocks")
os.makedirs(blocks_dir, exist_ok=True)
r = subprocess.run([promtool, "tsdb", "create-blocks-from", "openmetrics",
om, blocks_dir], capture_output=True, text=True)
if r.returncode != 0:
print(" promtool-Fehler:", r.stderr.strip()[:400], file=sys.stderr)
blocks_dir = None
else:
print(" " + (r.stdout.strip() or "Bloecke erzeugt"))
print(f"[4/5] Archiv packen …")
tar_path = os.path.join(args.out, f"apache-archive-{args.node}.tar.gz")
with tarfile.open(tar_path, "w:gz") as tf:
tf.add(om, arcname="openmetrics/apache-archive.openmetrics")
if blocks_dir:
tf.add(blocks_dir, arcname="blocks")
if args.include_raw:
# Rohlogs mitsichern: Prometheus hat nur die Aggregate — fuer die
# Fehlersuche im Einzelfall braucht es die Zeilen.
for f in files:
try:
tf.add(f, arcname="raw/" + f.lstrip("/"))
except (OSError, tarfile.TarError):
pass
print(f" {tar_path} ({os.path.getsize(tar_path)/1024/1024:.1f} MiB)")
print(f"[5/5] Fertig.")
print()
print(f" Uebergabe an den zentralen Server:")
print(f" scp {tar_path} mm@192.168.188.177:/tmp/")
print()
print(f" ============================================================")
print(f" EINSPIELEN — NUR in die ARCHIV-Instanz (:9091)")
print(f" ============================================================")
print(f" ssh mm@192.168.188.177 \\")
print(f" 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/{os.path.basename(tar_path)}'")
print()
print(f" Das Import-Skript prueft die Zeitstempel VOR dem Einspielen, legt ein")
print(f" Manifest an (Schutz gegen Doppel-Import) und schreibt ausschliesslich")
print(f" ins Archiv-Verzeichnis.")
print()
print(f" NIEMALS von Hand entpacken — insbesondere NICHT nach")
print(f" central-imac/data/prometheus/. Genau dieser Weg hat am 14.09.2026 die")
print(f" Zeitreferenz der Produktions-TSDB verschoben: 13 Nodes verloren ihre")
print(f" Metriken, jeder Live-Sample galt danach als 'too old'.")
print()
print(f" Hinweis: instance=\"{args.node}\", archived=\"1\" — im Dashboard von")
print(f" Live-Daten unterscheidbar (Filter archived!=\"1\" blendet das Archiv aus).")
return 0
if __name__ == "__main__":
sys.exit(main())
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
apache-log-survey.py — Bestandsaufnahme der Apache-Logs auf einem KeyHelp-Host.
MESSEN, NICHT RATEN: Bevor Logs archiviert werden, muss der Umfang bekannt sein.
Kardinalitaet ist der begrenzende Faktor in Prometheus — pro Request eine Serie
wuerde die TSDB sprengen. Die Aggregation richtet sich nach diesen Zahlen.
Aufruf (als root, damit alle User-Homes lesbar sind):
python3 apache-log-survey.py
Ausgabe: Dateien, Zeilen, Zeitraum, Status-Verteilung, Top-Domains.
Schreibt nichts, aendert nichts.
"""
import gzip
import glob
import os
import re
import sys
import time
from collections import Counter, defaultdict
from datetime import datetime, timezone
# Zugriffs- und Fehlerlogs, inkl. rotierter Varianten (.1, .2.gz).
PATTERNS = [
"/var/log/apache2/*access*.log", "/var/log/apache2/*access*.log.*",
"/var/log/apache2/*error*.log", "/var/log/apache2/*error*.log.*",
"/home/users/*/logs/*/access.log", "/home/users/*/logs/*/access.log.*",
"/home/users/*/logs/*/error.log", "/home/users/*/logs/*/error.log.*",
"/var/www/vhosts/*/logs/access_log*", "/var/www/vhosts/*/logs/error_log*",
]
# Combined / Common: ip - - [14/Sep/2026:00:47:01 +0200] "GET /x HTTP/1.1" 200 559
RE_LINE = re.compile(
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<req>[^"]*)" (?P<status>\d{3}|-) (?P<bytes>\d+|-)'
)
# Apache-Fehlerlog: [Mon Sep 14 00:47:01.123456 2026] [core:error] [pid 1] msg
RE_ERR = re.compile(r'^\[(?P<ts>[^\]]+)\] \[(?P<mod>[^\]:]+):(?P<lvl>\w+)\]')
TS_FORMATS = ["%d/%b/%Y:%H:%M:%S %z", "%a %b %d %H:%M:%S.%f %Y", "%a %b %d %H:%M:%S %Y"]
def open_maybe_gz(path):
try:
if path.endswith(".gz"):
return gzip.open(path, "rt", errors="replace")
return open(path, "r", errors="replace")
except OSError:
return None
def parse_ts(raw):
"""Zeitstempel parsen und IMMER auf zeitzonenbewusst (UTC) normalisieren.
Access-Logs haben eine Zeitzone ([14/Sep/2026:00:47:01 +0200]), Error-Logs
nicht. Ohne Normalisierung sind die Werte nicht vergleichbar — Python wirft
dann 'can't compare offset-naive and offset-aware datetimes'. Lokale Zeiten
werden als Systemzeitzone interpretiert (das ist auch ihre Bedeutung).
"""
for fmt in TS_FORMATS:
try:
dt = datetime.strptime(raw.strip(), fmt)
except ValueError:
continue
if dt.tzinfo is None:
dt = dt.astimezone() # lokale Zeit -> mit Offset versehen
return dt.astimezone(timezone.utc)
return None
def main():
# ----------------------------------------------------------------------
# FORTSCHRITT SICHTBAR MACHEN — sonst sieht der Scan wie ein Haenger aus.
#
# Real passiert (x22): der Aufruf war `python3 survey.py 2>&1 | tee bestand.txt`.
# Das Skript gibt ALLES erst am Ende aus (alle print() stehen hinter der
# Scan-Schleife), und Python puffert stdout in eine PIPE blockweise (4-8 KB,
# nicht zeilenweise). Die Gesamtausgabe ist aber nur ~1 KB — sie landet also
# erst beim Prozessende in der Datei. Bei 2 Mio Zeilen gemessen: 41 s lang
# 0 Bytes, dann alles auf einmal. Auf einem KeyHelp-Host mit tausenden
# Logdateien dauert das Minuten und liest sich wie ein Absturz.
#
# Drei Gegenmaßnahmen:
# 1. Zeilenpufferung erzwingen (reconfigure), damit jede Zeile sofort raus ist
# 2. einen Startbanner VOR dem Scan ausgeben
# 3. pro Datei eine Fortschrittszeile (mit flush)
# ----------------------------------------------------------------------
try:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass # aeltere Python-Version: dann eben gepuffert
files = sorted({f for pat in PATTERNS for f in glob.glob(pat)})
if not files:
print("Keine Apache-Logs gefunden. Laeuft Apache? (apache2ctl -v)")
return 1
total_bytes = 0
for _f in files:
try:
total_bytes += os.path.getsize(_f)
except OSError:
pass
def human(n):
for u in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.1f} {u}"
n /= 1024
return f"{n:.1f} TB"
print("=" * 72)
print("APACHE-LOG BESTANDSAUFNAHME")
print("=" * 72)
print(f"Scanne {len(files)} Dateien ({human(total_bytes)} auf Platte) …")
print("(grosse Bestaende dauern; dieser Fortschritt laeuft mit)")
print()
total_lines = 0
total_parsed = 0
total_err = 0
bytes_on_disk = 0
first_ts = None
last_ts = None
by_status = Counter()
by_domain = Counter()
by_method = Counter()
err_levels = Counter()
# Stunden-Buckets: hier entscheidet sich die Kardinalitaet der Archiv-Metriken.
hour_domain = set()
day_domain = set()
per_file = []
t0 = time.time()
for _idx, path in enumerate(files, 1):
fh = open_maybe_gz(path)
if fh is None:
print(f" [{_idx}/{len(files)}] NICHT LESBAR: {path}", flush=True)
continue
try:
_sz = os.path.getsize(path)
bytes_on_disk += _sz
except OSError:
_sz = 0
domain = os.path.basename(os.path.dirname(path)) or os.path.basename(path)
n_lines = n_ok = n_err = 0
f_first = f_last = None
with fh:
for line in fh:
n_lines += 1
m = RE_LINE.match(line)
if m:
n_ok += 1
by_status[m.group("status")] += 1
by_domain[domain] += 1
req = m.group("req") or ""
if " " in req:
by_method[req.split(" ", 1)[0]] += 1
ts = parse_ts(m.group("ts"))
if ts is not None:
if first_ts is None or ts < first_ts:
first_ts = ts
if last_ts is None or ts > last_ts:
last_ts = ts
if f_first is None or ts < f_first:
f_first = ts
if f_last is None or ts > f_last:
f_last = ts
hour_domain.add((ts.strftime("%Y-%m-%dT%H"), domain))
day_domain.add((ts.strftime("%Y-%m-%d"), domain))
continue
m = RE_ERR.match(line)
if m:
n_err += 1
err_levels[m.group("lvl").lower()] += 1
ts = parse_ts(m.group("ts"))
if ts is not None:
if first_ts is None or ts < first_ts:
first_ts = ts
if last_ts is None or ts > last_ts:
last_ts = ts
total_lines += n_lines
total_parsed += n_ok
total_err += n_err
per_file.append((path, n_lines, n_ok, n_err, f_first, f_last))
# Fortschritt pro Datei — und bei grossen Dateien auch bei jeder Zeile
# die Marke, damit lange Einzeldateien nicht stumm wirken.
print(f" [{_idx}/{len(files)}] {n_lines:>10,} Zeilen "
f"{human(_sz):>9} {path}", flush=True)
print()
print(f"Scan fertig in {time.time() - t0:.1f} s.")
print()
print(f"Zeilen total: {total_lines:,}")
print(f" Zugriffe: {total_parsed:,}")
print(f" Fehlerlog: {total_err:,}")
print(f" Unparsbar: {total_lines - total_parsed - total_err:,}")
if first_ts and last_ts:
span = last_ts - first_ts
print(f"Zeitraum: {first_ts:%Y-%m-%d %H:%M} .. {last_ts:%Y-%m-%d %H:%M} ({span.days} Tage)")
print()
print("--- Status-Verteilung (Top 10) ---")
for code, n in by_status.most_common(10):
print(f" {code} {n:>12,}")
print()
print("--- Methoden ---")
for meth, n in by_method.most_common(8):
print(f" {meth:<8} {n:>12,}")
print()
print("--- Top 15 Domains ---")
for dom, n in by_domain.most_common(15):
print(f" {n:>12,} {dom}")
print(f" ({len(by_domain)} Domains insgesamt)")
if err_levels:
print()
print("--- Fehler-Level ---")
for lvl, n in err_levels.most_common():
print(f" {lvl:<10} {n:>10,}")
print()
print("=" * 72)
print("KARDINALITAET der Archiv-Metriken (das entscheidet die Granularitaet)")
print("=" * 72)
print(f" pro Stunde + Domain: {len(hour_domain):,} Serien")
print(f" pro Tag + Domain: {len(day_domain):,} Serien")
print(f" pro Tag + Domain + Status: ~{len(day_domain) * max(1, len(by_status)):,} Serien")
print()
print("Faustregel: unter ~100k Serien ist unkritisch, darueber wird die TSDB traege.")
print()
print("--- Groesste Dateien ---")
for path, n_lines, n_ok, n_err, _, _ in sorted(per_file, key=lambda x: -x[1])[:10]:
print(f" {n_lines:>10,} Zeilen {path}")
return 0
if __name__ == "__main__":
sys.exit(main())
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ArchiveBox-Runner-Telemetrie fuer den Knoten archibald.
Laeuft als Nutzer `mm` per cron (jede Minute) und schreibt Prometheus-Exposition
in eine .prom-Datei, die eine Telegraf-Container-Instanz liest und per
Remote Write an den Collector (imac) schickt.
Warum ueberhaupt ein Exporteur: ArchiveBox' eigener Healthcheck prueft nur den
Webserver, nicht den Runner. Am 03.10.2026 blieb der Runner sieben Tage in einer
Neustart-Schleife (uid-Mismatch im Archivbaum, PermissionError beim mkdir des
Crawl-Verzeichnisses), waehrend Healthcheck und API gruen waren und jeder neue
Snapshot dauerhaft `queued` blieb.
Warum die Werte aus dem Container gelesen werden: der Host-Nutzer `mm` darf
index.sqlite3 (uid 911) nicht oeffnen; `docker exec` laeuft als Container-User.
Die Logdateien dagegen liegen im Host-Dateisystem und sind direkt lesbar.
Bewusst KEIN `node`-Label in der Ausgabe -- das setzt Telegraf per
[global_tags] (sonst entstehen zwei Serien derselben Identitaet).
"""
import os
import re
import subprocess
import sys
import time
ARCHIVEBOX_DIR = "/home/mm/archivebox"
OUT_DIR = "/home/mm/abx-telemetrie"
OUT_FILE = os.path.join(OUT_DIR, "abx.prom")
LOG_FILE = os.path.join(OUT_DIR, "exporteur.log")
CONTAINER = "archivebox-archivebox-1"
MAX_LOG_BYTES = 512 * 1024
LOGS = os.path.join(ARCHIVEBOX_DIR, "data", "logs")
SUPERVISORD_LOG = os.path.join(LOGS, "supervisord.log")
ERRORS_LOG = os.path.join(LOGS, "errors.log")
# Im Container: Status-Verteilung + Alter des aeltesten offenen Snapshots/Crawls.
DB_QUERY = r"""
import json, sqlite3, time
from datetime import datetime, timezone
c = sqlite3.connect("file:/data/index.sqlite3?mode=ro", uri=True)
now = time.time()
def age_of(value):
# ArchiveBox (Django, USE_TZ) speichert created_at als TEXT in UTC.
if value is None:
return None
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace(" ", "T"))
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = value
return now - dt.timestamp()
out = {}
out["snapshots"] = {str(s): int(n) for s, n in c.execute(
"select status, count(*) from core_snapshot group by 1")}
out["snapshots_total"] = int(c.execute(
"select count(*) from core_snapshot").fetchone()[0])
row = c.execute("select min(created_at) from core_snapshot where status!='sealed'").fetchone()
out["oldest_open_snapshot_age"] = age_of(row[0] if row else None)
# Alarmsignal: ein Snapshot im Zustand `queued` ist NICHT abgeholt. Ein
# `started`-Snapshot darf legitim lange laufen (Video-Crawl, gemessen > 1 h) --
# deshalb getrennt fuehren und `queued` explizit als 0 ausgeben, wenn nichts
# wartet: eine fehlende Serie waere von "keine Warteschlange" nicht zu
# unterscheiden.
row = c.execute("select min(created_at) from core_snapshot where status='queued'").fetchone()
out["oldest_queued_snapshot_age"] = age_of(row[0] if row else None) or 0
out["crawls"] = {str(s): int(n) for s, n in c.execute(
"select status, count(*) from crawls_crawl group by 1")}
row = c.execute("select min(created_at) from crawls_crawl where status!='sealed'").fetchone()
out["oldest_open_crawl_age"] = age_of(row[0] if row else None)
print("JSON:" + json.dumps(out))
"""
def sh(args, timeout=20):
try:
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired:
return 124, "", "timeout"
except Exception as exc: # noqa: BLE001
return 1, "", str(exc)
def line_count(path):
try:
with open(path, "rb") as fh:
return sum(1 for _ in fh)
except OSError:
return None
def stat_or_none(path):
try:
st = os.stat(path)
return st.st_size, st.st_mtime
except OSError:
return None, None
def main():
now = time.time()
metrics = [] # (name, wert) ohne Labels
labeled = [] # (name, labels-dict, wert)
notes = []
# --- Logdateien (Host-Sicht) ---
size, mtime = stat_or_none(ERRORS_LOG)
if size is not None:
metrics.append(("archivebox_runner_errors_log_bytes", size))
lines = line_count(ERRORS_LOG)
if lines is not None:
metrics.append(("archivebox_runner_errors_log_lines", lines))
metrics.append(("archivebox_runner_errors_log_age_seconds", int(now - mtime)))
spawns = None
try:
with open(SUPERVISORD_LOG, "r", errors="replace") as fh:
spawns = fh.read().count("spawned: 'worker_runner'")
except OSError:
notes.append("supervisord.log nicht lesbar")
if spawns is not None:
metrics.append(("archivebox_runner_spawns_total", spawns))
# --- Container: laeuft er, laeuft der Runner, was haengt in der Queue ---
rc, out, err = sh(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], timeout=15)
container_up = 1 if (rc == 0 and out.strip() == "true") else 0
metrics.append(("archivebox_container_up", container_up))
runner_up = 0
if container_up:
rc, out, _ = sh(["docker", "exec", CONTAINER, "sh", "-c",
"pgrep -f 'archivebox run' >/dev/null && echo 1 || echo 0"], timeout=20)
runner_up = 1 if out.strip().endswith("1") else 0
metrics.append(("archivebox_runner_up", runner_up))
if container_up:
rc, out, err = sh(["docker", "exec", CONTAINER, "python3", "-c", DB_QUERY], timeout=30)
payload = None
for line in out.splitlines():
if line.startswith("JSON:"):
import json
payload = json.loads(line[5:])
if payload is None:
notes.append("DB-Abfrage fehlgeschlagen: %s" % (err.strip()[:120] or rc))
else:
metrics.append(("archivebox_snapshots_total", payload["snapshots_total"]))
for status, n in sorted(payload["snapshots"].items()):
labeled.append(("archivebox_snapshots", {"status": status}, n))
for status, n in sorted(payload["crawls"].items()):
labeled.append(("archivebox_crawls", {"status": status}, n))
age = payload.get("oldest_open_snapshot_age")
if age is not None:
metrics.append(("archivebox_oldest_open_snapshot_age_seconds", int(age)))
metrics.append(("archivebox_oldest_queued_snapshot_age_seconds",
int(payload.get("oldest_queued_snapshot_age") or 0)))
age = payload.get("oldest_open_crawl_age")
if age is not None:
metrics.append(("archivebox_oldest_open_crawl_age_seconds", int(age)))
else:
notes.append("Container laeuft nicht")
metrics.append(("archivebox_telemetrie_timestamp_seconds", int(now)))
# --- Ausgabe (atomar) ---
os.makedirs(OUT_DIR, mode=0o755, exist_ok=True)
buf = ["# HELP archivebox_telemetrie_timestamp_seconds Zeitpunkt der Erhebung.",
"# TYPE archivebox_telemetrie_timestamp_seconds gauge",
"# TYPE archivebox_container_up gauge",
"# TYPE archivebox_runner_up gauge",
"# TYPE archivebox_runner_spawns_total counter",
"# TYPE archivebox_snapshots gauge",
"# TYPE archivebox_crawls gauge"]
for name, val in metrics:
buf.append("%s %s" % (name, val))
for name, labels, val in labeled:
lbl = ",".join('%s="%s"' % (k, v) for k, v in sorted(labels.items()))
buf.append("%s{%s} %s" % (name, lbl, val))
tmp = OUT_FILE + ".tmp"
with open(tmp, "w") as fh:
fh.write("\n".join(buf) + "\n")
os.chmod(tmp, 0o644)
os.replace(tmp, OUT_FILE)
# --- Laufprotokoll (gedeckelt) ---
try:
if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > MAX_LOG_BYTES:
os.replace(LOG_FILE, LOG_FILE + ".1")
with open(LOG_FILE, "a") as fh:
fh.write("%s ok container=%d runner=%d spawns=%s %s\n" % (
time.strftime("%Y-%m-%dT%H:%M:%S"), container_up, runner_up,
spawns, ("; ".join(notes) if notes else "")))
except OSError:
pass
if notes:
print("Hinweise: " + "; ".join(notes), file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""docker-events-loki.py — Docker-Events (Neustart, OOM-Kill, Stop, Health)
dauerhaft nach Loki. Damit ist "wer hat den Container neu gestartet" in
Grafana beantwortet statt in Rätselraten.
ARCHITEKTUR (nach zwei echten Fehlern in der ersten Fassung):
* Docker hält Events nur im Speicher. Ein Poller mit `--since` verliert bei
Ausfällen alles dazwischen. Deshalb ein dauerhafter `docker events`-Stream.
* Ein RAM-Puffer verliert Events, wenn Loki nicht mithält. GEMESSEN auf x05:
ein POST über den NetBird-Reverse-Proxy braucht 11-19 s (TLS-Handshake
allein nur 0,5-1,6 s, Loki direkt 1,3 ms) — der Weg ist der Flaschenhals.
Mit RAM-Puffer und 5-s-Flush lief die Queue voll und die erste Fassung
VERWARF Events. Bei einem Audit-Stream ist Verwerfen inakzeptabel.
* Daher: jedes Event geht zuerst in eine Spool-DATEI (append, O_APPEND).
Der Sender arbeitet die Datei ab und trunkiert nach bestätigtem Push.
Loki-Ausfall oder langsamer Proxy kosten damit nur Verzögerung, keine Daten.
* Die Spool-Größe ist begrenzt; bei Überlauf werden die ÄLTESTEN verworfen
und laut gemeldet (auf diesem Host mit 967 MiB RAM darf ein Log-Forwarder
nie die Ursache des nächsten OOM werden).
ENV (aus /opt/observability-telegraf/.env):
LOKI_URL, TELEGRAF_TOKEN, optional NODE_NAME
"""
import json
import os
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
STATE_DIR = os.environ.get("DOCKER_EVENTS_STATE", "/var/lib/docker-events")
LOKI_URL = os.environ.get("LOKI_URL", "").rstrip("/")
TOKEN = os.environ.get("TELEGRAF_TOKEN", "")
NODE = os.environ.get("NODE_NAME") or os.uname().nodename
SPOOL = os.path.join(STATE_DIR, "spool.jsonl")
CURSOR = os.path.join(STATE_DIR, "cursor")
HEALTH_FILE = os.path.join(STATE_DIR, "heartbeat")
SPOOL_MAX_BYTES = 20 * 1024 * 1024 # ~50k Events; danach älteste weg
BATCH_LINES = 50
BATCH_SECONDS = 10.0
PUSH_TIMEOUT = 120 # Proxy-Weg braucht 11-19 s (gemessen)
IDLE_SLEEP = 2.0
def log(msg):
print(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {msg}", flush=True)
def push(lines):
"""lines: Liste von (ns_string, message_string, labels_dict)"""
if not lines:
return True
streams = {}
for ns, msg, labels in lines:
key = tuple(sorted(labels.items()))
streams.setdefault(key, {"stream": dict(labels), "values": []})
streams[key]["values"].append([ns, msg])
body = json.dumps({"streams": list(streams.values())}).encode()
req = urllib.request.Request(
f"{LOKI_URL}/loki/api/v1/push", data=body, method="POST",
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}"})
try:
with urllib.request.urlopen(req, timeout=PUSH_TIMEOUT) as r:
return 200 <= r.status < 300
except urllib.error.HTTPError as e:
detail = ""
try:
detail = e.read()[:200].decode(errors="replace")
except Exception:
pass
log(f"LOKI HTTP {e.code}: {detail!r}")
# 4xx sind dauerhaft -> Batch verwerfen, sonst blockiert er ewig.
return "permanent" if 400 <= e.code < 500 else False
except Exception as e:
log(f"LOKI nicht erreichbar ({type(e).__name__}): {e}")
return False
def spool_append(rec):
"""Ein Event atomar anhängen. O_APPEND: kein Verlust bei gleichzeitigem Lesen."""
line = json.dumps(rec, ensure_ascii=False) + "\n"
fd = os.open(SPOOL, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, line.encode())
finally:
os.close(fd)
def spool_rotate_if_big():
"""Zu groß? Die ÄLTESTEN verwerfen (neue Events sind die wertvollen)."""
try:
size = os.path.getsize(SPOOL)
except OSError:
return
if size <= SPOOL_MAX_BYTES:
return
keep = SPOOL_MAX_BYTES // 2
try:
with open(SPOOL, "rb") as fh:
fh.seek(-keep, os.SEEK_END)
tail = fh.read()
# an der ersten vollständigen Zeile abschneiden
nl = tail.find(b"\n")
if nl >= 0:
tail = tail[nl + 1:]
tmp = SPOOL + ".rot"
with open(tmp, "wb") as fh:
fh.write(tail)
os.replace(tmp, SPOOL)
log(f"WARNUNG: Spool war {size/1e6:.1f} MB, aelteste Haelfte verworfen")
except OSError as e:
log(f"WARNUNG: Spool-Rotation fehlgeschlagen: {e}")
def collect_thread():
"""docker events -> Spool. Läuft nie blockierend, damit der Kernel-Puffer
nicht überläuft (sonst verliert Docker selbst Events)."""
while True:
try:
proc = subprocess.Popen(
["docker", "events", "--format", "{{json .}}"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1)
except FileNotFoundError:
log("FEHLER: docker nicht gefunden — warte 30 s")
time.sleep(30)
continue
assert proc.stdout is not None
log("docker-events-Stream verbunden")
for raw in proc.stdout:
raw = raw.strip()
if not raw:
continue
try:
ev = json.loads(raw)
except json.JSONDecodeError:
continue
ts = int(ev.get("timeNano") or ev.get("time") or 0)
if ts and ts < 10**12: # war in Sekunden
ts *= 10**9
if not ts:
ts = int(time.time() * 10**9)
act = ev.get("Action") or ev.get("status") or "unknown"
actor = ev.get("Actor") or {}
attrs = actor.get("Attributes") or {}
name = attrs.get("name") or str(ev.get("id", ""))[:12] or "?"
try:
spool_append({
"ns": str(ts),
"labels": {
"node": NODE,
"source": "docker-events",
"action": act.replace(" ", "_")[:40],
"name": name[:60],
},
"msg": json.dumps({
"action": act, "name": name,
"image": str(attrs.get("image", ""))[:80],
"exitCode": attrs.get("exitCode", ""),
"oomKilled": attrs.get("oomKilled", ""),
"signal": attrs.get("signal", ""),
"time": time.strftime("%Y-%m-%dT%H:%M:%S",
time.gmtime(ts / 1e9)),
}, ensure_ascii=False),
})
except OSError as e:
log(f"FEHLER beim Spool-Schreiben: {e}")
log("WARNUNG: docker-events-Stream endete — Neustart in 10 s")
time.sleep(10)
def send_thread():
"""Spool -> Loki über einen Cursor.
Der Cursor (Byte-Offset) wird NACH bestätigtem Push gespeichert. Damit
gibt es kein Umschreiben der Datei und kein Rennen mit dem Collector:
der Collector hängt nur an, der Sender liest nur vorwärts.
at-least-once: nach einem Absturz wird der letzte Batch erneut gesendet.
Für ein Audit-Log ist ein doppeltes Event harmlos, ein fehlendes nicht.
"""
while True:
try:
if not os.path.exists(SPOOL):
time.sleep(IDLE_SLEEP)
continue
try:
with open(CURSOR) as fh:
offset = int(fh.read().strip() or 0)
except (OSError, ValueError):
offset = 0
size = os.path.getsize(SPOOL)
if offset > size: # Spool wurde rotiert
offset = 0
if offset == size:
spool_rotate_if_big()
time.sleep(IDLE_SLEEP)
continue
with open(SPOOL, "rb") as fh:
fh.seek(offset)
chunk = fh.read(SPOOL_MAX_BYTES // 8)
# nur vollständige Zeilen verarbeiten
cut = chunk.rfind(b"\n")
if cut < 0:
time.sleep(IDLE_SLEEP)
continue
complete = chunk[:cut + 1]
batch = []
for ln in complete.decode(errors="replace").splitlines()[:BATCH_LINES]:
try:
rec = json.loads(ln)
batch.append((rec["ns"], rec["msg"], rec["labels"]))
except (json.JSONDecodeError, KeyError):
continue
# Offset bis zum Ende des verarbeiteten Teils
used = 0
for ln in complete.decode(errors="replace").splitlines()[:BATCH_LINES]:
used += len(ln.encode()) + 1
if not batch:
offset += used
with open(CURSOR, "w") as fh:
fh.write(str(offset))
continue
res = push(batch)
if res is True or res == "permanent":
offset += used
tmp = CURSOR + ".tmp"
with open(tmp, "w") as fh:
fh.write(str(offset))
os.replace(tmp, CURSOR)
try:
with open(HEALTH_FILE, "w") as fh:
fh.write(str(int(time.time())))
except OSError:
pass
if res == "permanent":
log(f"{len(batch)} Ereignisse dauerhaft abgelehnt, verworfen")
else:
time.sleep(IDLE_SLEEP * 2) # Loki down: kurz warten
except Exception as e:
log(f"FEHLER im Sender ({type(e).__name__}): {e}")
time.sleep(5)
def main():
if not LOKI_URL or not TOKEN:
log("FEHLER: LOKI_URL oder TELEGRAF_TOKEN fehlt")
return 2
os.makedirs(STATE_DIR, exist_ok=True)
log(f"starte docker-events -> {LOKI_URL} (node={NODE})")
threading.Thread(target=collect_thread, daemon=True).start()
threading.Thread(target=send_thread, daemon=True).start()
while True:
time.sleep(3600)
return 0
if __name__ == "__main__":
sys.exit(main())
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# docker-events-watcher.sh — Wrapper fuer docker-events-loki.py (systemd).
#
# Liest LOKI_URL und TELEGRAF_TOKEN aus der .env des Telegraf-Stacks, damit
# keine zweite Credential-Datei entsteht. Pfad per ENV uebersteuerbar.
set -u
ENVF="${DOCKER_EVENTS_ENV:-/opt/observability-telegraf/.env}"
if [ -r "$ENVF" ]; then
set -a
# shellcheck disable=SC1090
. "$ENVF"
set +a
fi
exec /usr/bin/python3 /usr/local/bin/docker-events-loki.py
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# =============================================================================
# ArchiveBox-Runner-Telemetrie auf einem Knoten OHNE sudo einrichten.
#
# Drei Teile, alle ohne Root:
# 1. Exporteur-Skript nach ~/bin (liest Logs + DB im Container, schreibt .prom)
# 2. Cron-Eintrag (jede Minute) -- der Cron-Daemon des Hosts, kein Nutzer-Timer
# (linger=no wuerde Nutzer-Units beim Abmelden abraeumen)
# 3. Telegraf-Container (Docker-Gruppe) mit Remote Write an den Collector
#
# Aufruf: sh install-archivebox-telemetrie.sh [--dry-run]
# Vorbedingungen: python3, docker (Docker-Gruppe), cron laufen.
# =============================================================================
set -eu
DRY="${1:-}"
NODE="${NODE_NAME:-archibald}"
HOME_DIR="${HOME:-/home/mm}"
BIN="$HOME_DIR/bin/archivebox-telemetrie.py"
STACK_DIR="$HOME_DIR/stacks/telegraf-archibald"
PROM_URL="${PROM_URL:-http://100.83.134.46:9090/api/v1/write}"
say() { printf '%s\n' "$*"; }
run() {
if [ "$DRY" = "--dry-run" ]; then say "DRY: $*"; else eval "$@"; fi
}
say "== Vorbedingungen =="
for c in python3 docker crontab; do
command -v "$c" >/dev/null 2>&1 || { say "FEHLER: $c fehlt"; exit 1; }
say "ok: $c -> $(command -v "$c")"
done
docker ps >/dev/null 2>&1 || { say "FEHLER: Docker nicht nutzbar (Gruppe?)"; exit 1; }
say "ok: Docker nutzbar"
say "== 1. Exporteur =="
run "mkdir -p '$HOME_DIR/bin' '$HOME_DIR/abx-telemetrie'"
run "chmod 755 '$HOME_DIR/abx-telemetrie'"
if [ "$DRY" != "--dry-run" ]; then
here=$(cd "$(dirname "$0")" && pwd)
run "cp '$here/archivebox-telemetrie.py' '$BIN'"
run "chmod 755 '$BIN'"
fi
say "== 2. Cron (jede Minute, idempotent) =="
LINE="* * * * * LC_ALL=C /usr/bin/python3 $BIN >/dev/null 2>&1"
if [ "$DRY" = "--dry-run" ]; then
say "DRY: crontab-Zeile -> $LINE"
else
tmp=$(mktemp)
crontab -l 2>/dev/null | grep -v 'archivebox-telemetrie.py' > "$tmp" || true
printf '%s\n' "$LINE" >> "$tmp"
crontab "$tmp"
rm -f "$tmp"
say "crontab jetzt:"
crontab -l | sed 's/^/ /'
fi
say "== 3. Telegraf-Container =="
run "mkdir -p '$STACK_DIR'"
here=$(cd "$(dirname "$0")" && pwd)
if [ "$DRY" != "--dry-run" ]; then
run "cp '$here/../stacks/telegraf-container/docker-compose.yml' '$STACK_DIR/'"
run "cp '$here/../stacks/telegraf-container/telegraf.conf' '$STACK_DIR/'"
# NODE_NAME/PROM_URL aus der Datei gegen die hier gesetzten Werte stellen.
run "sed -i \"s|NODE_NAME: .*|NODE_NAME: $NODE|\" '$STACK_DIR/docker-compose.yml'"
run "sed -i \"s|PROM_URL: .*|PROM_URL: $PROM_URL|\" '$STACK_DIR/docker-compose.yml'"
fi
say "== Probelauf des Exporteurs =="
if [ "$DRY" != "--dry-run" ]; then
run "/usr/bin/python3 '$BIN'"
say "--- $HOME_DIR/abx-telemetrie/abx.prom (Kopf) ---"
head -20 "$HOME_DIR/abx-telemetrie/abx.prom"
fi
say "== Container starten =="
if [ "$DRY" != "--dry-run" ]; then
( cd "$STACK_DIR" && docker compose up -d )
sleep 20
docker logs --since 60s telegraf-archibald 2>&1 | grep -c 'E!' | sed 's/^/ E!-Zeilen: /'
docker exec telegraf-archibald sh -c 'cat /var/lib/abx-prom/abx.prom | head -3' \
| sed 's/^/ Container sieht: /'
fi
say ""
say "== Abnahme (auf dem Collector ausfuehren) =="
say "curl -s --data-urlencode 'query=archivebox_telemetrie_timestamp_seconds' http://<collector>:9090/api/v1/query"
say "curl -s --data-urlencode 'query=count by (node)(archivebox_runner_up)' http://<collector>:9090/api/v1/query"
say "OK -- Knoten $NODE eingerichtet."
+155
View File
@@ -0,0 +1,155 @@
#!/bin/sh
# Installiert die llama-swap-Telemetrie als NUTZER-Instanz von Telegraf.
#
# WOFUER: Knoten, auf denen llama-swap laeuft, aber kein sudo existiert (ROG).
# Die System-Instanz (install.sh) kann hier nicht greifen: sie schreibt nach
# /etc/telegraf/telegraf.d und laeuft als root-Dienst. Ausserdem liest sie das
# Systemjournal mit priority>=warning — llama-swaps INFO-Zeilen (Modellstart,
# Ports) liegen im NUTZER-Journal und wurden deshalb nie nach Loki geschickt.
# Diese Instanz darf `journalctl --user-unit` lesen und braucht kein root.
#
# ZUGANGSDATEN: kommen NICHT in die Konfiguration. Das Systemd-Unit setzt
# EnvironmentFile=/etc/default/telegraf; Telegraf expandiert ${PROM_URL},
# ${LOKI_URL} und ${TELEGRAF_TOKEN} beim Laden. Damit bleibt
# /etc/default/telegraf (das der System-Installer geschrieben hat) die einzige
# Quelle pro Knoten.
#
# BENUTZUNG (auf dem Zielknoten, als der Nutzer, dem llama-swap gehoert):
# sh install-llamaswap-user.sh # Dateien liegen neben dem Skript
# sh install-llamaswap-user.sh --raw <basis> # sonst von dort laden
# sh install-llamaswap-user.sh --dry-run # nur zeigen, nichts schreiben
#
# IDEMPOTENT: mehrfaches Ausfuehren ist erlaubt; Unit wird neu geladen und
# neu gestartet. Der Journal-Cursor bleibt erhalten (kein doppelter Versand).
set -eu
RAW_BASE=""
DRY=0
HIER="$(cd "$(dirname "$0")" && pwd)"
while [ $# -gt 0 ]; do
case "$1" in
--raw) RAW_BASE="$2"; shift 2 ;;
--dry-run) DRY=1; shift ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) printf 'Unbekannte Option: %s\n' "$1" >&2; exit 2 ;;
esac
done
BIN_DIR="$HOME/bin"
CONF_DIR="$HOME/.config/telegraf-llamaswap"
UNIT_DIR="$HOME/.config/systemd/user"
STATE_DIR="$HOME/.local/state/llamaswap-logs"
sag() { printf ' %s\n' "$*"; }
schritt() { printf '\n== %s\n' "$*"; }
# ---------------------------------------------------------------- Vorbedingungen
schritt "Vorbedingungen"
if [ ! -r /etc/default/telegraf ]; then
printf 'FEHLER: /etc/default/telegraf fehlt oder ist nicht lesbar.\n' >&2
printf ' Ohne PROM_URL/LOKI_URL/TELEGRAF_TOKEN gibt es kein Ziel.\n' >&2
printf ' Zuerst den Knoten-Installer laufen lassen (install.sh).\n' >&2
exit 1
fi
for var in NODE_NAME PROM_URL LOKI_URL TELEGRAF_TOKEN; do
if ! grep -q "^${var}=" /etc/default/telegraf; then
printf 'FEHLER: %s fehlt in /etc/default/telegraf.\n' "$var" >&2
exit 1
fi
done
sag "/etc/default/telegraf vorhanden (NODE_NAME, PROM_URL, LOKI_URL, TELEGRAF_TOKEN)"
TGBIN=""
for kandidat in /usr/bin/telegraf /usr/local/bin/telegraf; do
[ -x "$kandidat" ] && TGBIN="$kandidat" && break
done
[ -n "$TGBIN" ] || { printf 'FEHLER: telegraf-Binary nicht gefunden.\n' >&2; exit 1; }
sag "Telegraf: $TGBIN ($("$TGBIN" --version | head -1))"
# ------------------------------------------------------------------- Dateien holen
hole() { # hole <relativer pfad im repo> <ziel>
_rel="$1"; _dst="$2"
if [ -f "$HIER/../$_rel" ]; then
cp "$HIER/../$_rel" "$_dst"; sag "aus Repo: $_rel"
elif [ -n "$RAW_BASE" ]; then
if command -v curl >/dev/null 2>&1; then
curl -fsSL -m 60 "$RAW_BASE/$_rel" -o "$_dst"; sag "geladen: $_rel"
else
printf 'FEHLER: curl fehlt und $_rel nicht lokal vorhanden.\n' >&2; exit 1
fi
else
printf 'FEHLER: %s weder lokal noch --raw gesetzt.\n' "$_rel" >&2; exit 1
fi
[ -s "$_dst" ] || { printf 'FEHLER: %s ist leer.\n' "$_dst" >&2; exit 1; }
}
if [ "$DRY" -eq 1 ]; then
schritt "Trockenlauf — es wird nichts geschrieben"
sag "wuerde anlegen: $BIN_DIR/llamaswap-telemetrie.py, $BIN_DIR/llamaswap-logs.py"
sag "wuerde anlegen: $CONF_DIR/telegraf.conf (0600)"
sag "wuerde anlegen: $UNIT_DIR/telegraf-llamaswap.service"
sag "wuerde starten: systemctl --user enable --now telegraf-llamaswap.service"
exit 0
fi
schritt "Programme nach $BIN_DIR"
mkdir -p "$BIN_DIR" "$CONF_DIR" "$UNIT_DIR" "$STATE_DIR"
hole scripts/llamaswap-telemetrie.py "$BIN_DIR/llamaswap-telemetrie.py"
hole scripts/llamaswap-logs.py "$BIN_DIR/llamaswap-logs.py"
chmod 755 "$BIN_DIR/llamaswap-telemetrie.py" "$BIN_DIR/llamaswap-logs.py"
schritt "Konfiguration nach $CONF_DIR"
hole telegraf/llamaswap-user.conf "$CONF_DIR/telegraf.conf"
chmod 600 "$CONF_DIR/telegraf.conf" # 0600: hier stehen keine Geheimnisse,
# aber die Datei ist auch nicht oeffentlich
schritt "Unit nach $UNIT_DIR"
hole telegraf/llamaswap-user.service "$UNIT_DIR/telegraf-llamaswap.service"
schritt "Probelauf der beiden Programme (vor dem Dienst)"
set -a; . /etc/default/telegraf; set +a
metriken="$(python3 "$BIN_DIR/llamaswap-telemetrie.py" | wc -l)"
logs="$(python3 "$BIN_DIR/llamaswap-logs.py" | wc -l)"
sag "llamaswap-telemetrie.py: $metriken Zeilen"
sag "llamaswap-logs.py: $logs Zeilen (0 ist normal, wenn gerade nichts passiert)"
if [ "$metriken" -eq 0 ]; then
sag "WARNUNG: keine Metriken — laeuft llama-swap auf diesem Knoten?"
fi
schritt "Telegraf-Konfiguration pruefen (--test, keine Ausgabe an die Ziele)"
if ! "$TGBIN" --config "$CONF_DIR/telegraf.conf" --test --test-wait 5 >/dev/null 2>&1; then
printf 'FEHLER: Telegraf meldet einen Konfigurationsfehler.\n' >&2
"$TGBIN" --config "$CONF_DIR/telegraf.conf" --test --test-wait 5 2>&1 | tail -5 >&2
exit 1
fi
sag "Konfiguration in Ordnung"
schritt "Dienst starten"
systemctl --user daemon-reload
systemctl --user enable --now telegraf-llamaswap.service
sleep 6
zustand="$(systemctl --user is-active telegraf-llamaswap.service)"
sag "telegraf-llamaswap.service: $zustand"
[ "$zustand" = "active" ] || {
printf 'FEHLER: Dienst laeuft nicht. Log:\n' >&2
journalctl --user -u telegraf-llamaswap.service -n 15 --no-pager >&2
exit 1
}
cat <<'HINWEIS'
== Fertig. Was jetzt laeuft
* llama-swap /metrics -> Prometheus (Host-, GPU-Werte)
* llamaswap_activity_* -> Token/Anfragen je Modell
* llamacpp_* -> Token je laufendem Profil (nur mit --metrics)
* Nutzer-Journal der Units -> Loki (unit="llama-swap.service")
Ohne `linger` endet der Dienst mit der Sitzung des Nutzers:
loginctl enable-linger $USER (braucht meist root, einmalig)
Pruefen:
systemctl --user status telegraf-llamaswap.service
journalctl --user -u telegraf-llamaswap.service -n 20
HINWEIS
+173
View File
@@ -0,0 +1,173 @@
#!/bin/bash
# keyhelp-log-backfill.sh — Alte Apache-Logs eines KeyHelp-Hosts messen, parsen,
# als Archiv bereitstellen. (x22 und x14 sind damit beide bedient.)
# DIESES SKRIPT AENDERT NICHTS AM LAUFENDEN DIENST.
#
# SICHERHEIT (die drei Wege, die schon schiefgingen):
# 1. Es importiert NICHTS in die Produktions-TSDB. Der Import laeuft
# ausschliesslich ueber prom-archive-import.sh --file auf dem imac,
# der nur in die getrennte Archiv-Instanz (:9091) schreibt.
# 2. Es stoppt nichts und loescht nichts. Ausgabe geht nach /root/apache-archive.
# 3. Die Ausgabe wird VOR der Uebergabe geprueft (Einheiten, Zukunft, Inhalt).
#
# AUFRUF auf dem Host (als root):
# curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/scripts/keyhelp-log-backfill.sh -o /root/keyhelp-backfill.sh
# bash /root/keyhelp-backfill.sh # MESSEN (nichts wird veraendert)
# bash /root/keyhelp-backfill.sh --backfill # parsen und Tarball bauen
#
# Danach steht am Ende der genaue Befehl fuer den imac.
set -eu
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
OUT_DIR="${OUT_DIR:-/root/apache-archive}"
NODE="${NODE:-$(hostname -f 2>/dev/null || hostname)}"
BACKFILL=0
[ "${1:-}" = "--backfill" ] && BACKFILL=1
log() { printf '\033[1;34m[backfill]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[backfill] WARNUNG:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[backfill] FEHLER:\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Bitte als root ausfuehren."
# --------------------------------------------------------------------------
# 1) Bestand MESSEN — nicht raten
# --------------------------------------------------------------------------
log "Bestandsaufnahme der Apache-Logs (aendert nichts) …"
mkdir -p /tmp/log-archiv
curl -fsSL -m 30 "$REPO_RAW/scripts/apache-log-survey.py" -o /tmp/log-archiv/survey.py \
|| die "survey.py konnte nicht geladen werden."
# ACHTUNG: der Survey kennt KEIN --node (er beschreibt nur den lokalen Host).
# Der Fortschritt wird nach stdout UND in die Datei geschrieben; das Skript
# erzwingt Zeilenpufferung, sonst bleibt bestand.txt bei einer Pipe bis zum
# Prozessende leer und der laufende Scan sieht wie ein Haenger aus.
python3 /tmp/log-archiv/survey.py 2>&1 | tee /tmp/log-archiv/bestand.txt
# Wie viel Historie liegt wirklich vor? Das entscheidet ueber den Nutzen.
FILES=$(find /var/log/apache2 /home/users -name 'access.log*' -o -name 'error.log*' 2>/dev/null | wc -l)
log "Gefundene Logdateien (inkl. rotierte/.gz): $FILES"
if [ "$BACKFILL" -eq 0 ]; then
echo
log "Das war NUR die Messung — es wurde nichts veraendert."
echo " Weiter mit: bash $0 --backfill"
echo " Bestand: /tmp/log-archiv/bestand.txt"
exit 0
fi
# --------------------------------------------------------------------------
# 2) Archiv bauen
# --------------------------------------------------------------------------
log "Archiver laden …"
curl -fsSL -m 30 "$REPO_RAW/scripts/apache-log-archive.py" -o /tmp/log-archiv/archive.py \
|| die "apache-log-archive.py konnte nicht geladen werden."
log "Logs parsen (kann bei viel Historie dauern) …"
python3 /tmp/log-archiv/archive.py --node "$NODE" --out "$OUT_DIR" --include-raw 2>&1 | tail -20
# --------------------------------------------------------------------------
# 3) Selbst pruefen, BEVOR etwas weitergereicht wird
# --------------------------------------------------------------------------
OM="$OUT_DIR/apache-archive.openmetrics"
[ -s "$OM" ] || die "Keine Ausgabe erzeugt: $OM fehlt oder ist leer."
log "Selbstpruefung der Ausgabe"
python3 - "$OM" <<'PY' || exit 1
import sys, time, collections
path = sys.argv[1]
pts = [l.rstrip("\n") for l in open(path) if l and not l.startswith("#") and l.strip()]
if not pts:
print(" FEHLER: keine Datenpunkte"); sys.exit(1)
ts = [int(l.rsplit(" ", 1)[1]) for l in pts]
uniq = sorted(set(ts))
now = int(time.time())
f = lambda t: time.strftime("%d.%m.%Y %H:%M", time.gmtime(t))
print(f" Datenpunkte: {len(pts):,}")
print(f" Zeitraum: {f(uniq[0])} .. {f(uniq[-1])} UTC")
# WICHTIG: min..max allein ist irrefuehrend. Ein EINZELNER Punkt mit altem
# Zeitstempel (eine Logzeile von 2024 in einer sonst aktuellen Datei) laesst den
# Backfill wie zwei Jahre aussehen, obwohl nur 30 Tage Substanz da sind.
# Deshalb die tatsaechlich belegten Stunden nennen und die Abdeckung pruefen.
hours = sorted({t - (t % 3600) for t in uniq})
span_h = max(1, (hours[-1] - hours[0]) // 3600 + 1)
cover = 100.0 * len(hours) / span_h
print(f" belegte Stunden: {len(hours):,} von {span_h:,} im Zeitraum ({cover:.1f} % Abdeckung)")
if len(hours) < span_h * 0.5:
# Die groessten Luecken benennen — das sind meist fehlende/rotierte Logs.
gaps = [(hours[i+1] - hours[i], hours[i], hours[i+1]) for i in range(len(hours)-1)]
gaps.sort(reverse=True)
print(" WARNUNG: die Historie ist lueckenhaft. Groesste Luecken:")
for g, a, b in gaps[:3]:
if g > 3600:
print(f" {g/86400:.0f} Tage {f(a)} -> {f(b)}")
print(" Ursache meist: rotierte/alte Logs fehlen auf dem Quellhost.")
if len(hours) <= 3:
print(" ACHTUNG: nur wenige Stunden — vermutlich einzelne alte Logzeilen,")
print(" kein zusammenhaengender Zeitraum. Vor dem Import pruefen.")
domains = collections.Counter()
for l in pts:
i = l.find('domain="')
if i >= 0:
j = l.find('"', i + 8)
domains[l[i+8:j]] += 1
print(f" Domains: {len(domains)}")
for d, n in domains.most_common(8):
print(f" {d}: {n} Punkte")
# Einheiten-Wache: alles nach 2100 ist kein Sekundenwert
if uniq[-1] > 4102444800:
print(" FEHLER: Zeitstempel nach dem Jahr 2100 -> Millisekunden statt Sekunden")
print(" promtool wuerde Bloecke im Jahr 58.660 erzeugen. Abbruch.")
sys.exit(1)
print(" Einheit: OK (Sekunden)")
# Zukunft: der Fehler, der die Produktion kosten kann
z = [t for t in uniq if t > now + 300]
if z:
print(f" FEHLER: {len(z)} Punkte in der ZUKUNFT (max {f(z[-1])}).")
print(" Ursache: falsche Systemuhr oder Log-Zeitzone. Abbruch.")
sys.exit(1)
print(" Zukunft: OK")
if len(domains) < 2:
print(f" WARNUNG: nur {len(domains)} Domain(s) erkannt — Logs vollstaendig?")
PY
TAR="$OUT_DIR/apache-archive.tar.gz"
[ -s "$TAR" ] && log "Tarball: $TAR ($(du -h "$TAR" | cut -f1))"
# --------------------------------------------------------------------------
# 4) Uebergabe — der sichere Weg
# --------------------------------------------------------------------------
cat <<EOF
============================================================
FERTIG. Naechster Schritt auf dem imac.
============================================================
WICHTIG: NICHT von Hand entpacken. Die Bloecke gehoeren in die getrennte
Archiv-Instanz (:9091), nie in die Produktions-TSDB. Genau dieser Weg hat am
14.09.2026 die Zeitreferenz verschoben — 13 Nodes verloren ihre Metriken.
# 1) Uebertragen (vom Host aus, im NetBird)
scp $OM mm@100.83.134.46:/tmp/apache-archive.openmetrics
# 2) Auf dem imac einspielen (prueft Zukunft, Manifest, Gegenprobe)
ssh mm@192.168.188.177 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/apache-archive.openmetrics'
# 3) Zweiter Lauf derselben Datei wird automatisch uebersprungen.
# Anzeigen, was schon importiert ist:
ssh mm@192.168.188.177 'sudo /usr/local/bin/prom-archive-import.sh --list'
# 4) Pruefen (step >= 1h wegen Stunden-Buckets!)
ssh mm@192.168.188.177 'curl -s -G http://127.0.0.1:9091/api/v1/query_range \\
--data-urlencode "query=apache_archive_requests_total" \\
--data-urlencode "start=\$(date -d "-30 days" +%s)" \\
--data-urlencode "end=\$(date +%s)" --data-urlencode "step=3600" | head -c 400'
Die Produktions-TSDB bleibt bei diesem Weg unberuehrt. Der Tarball mit den
Rohlogs liegt unter $TAR — aufheben, er ist die Quelle fuer Einzelfaelle.
EOF
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Logs der llama-swap-Nutzer-Units -> Influx Line Protocol fuer Telegraf.
Warum eigene Quelle:
llama-swap und kalina-backends laufen als *User*-Units. Der Knoten-Telegraf
liest das Systemjournal ueber journal-tail.sh und filtert dort zusaetzlich
auf priority >= warning. Die INFO-Zeilen von llama-swap (Modellstart,
Port-Vergabe, Weckpfad) und alles von kalina-backends kommen deshalb dort
nie an - Loki hatte fuer den ROG keine einzige llama-swap-Zeile.
Ein Nutzer-Prozess darf --user-unit lesen; damit ist kein sudo noetig.
Ausgabe: dieselbe Zeilenform wie journal-tail.sh (measurement `journal`, Tags
unit/ident/priority/jhost, Feld message), damit die bestehenden Log-Panels im
Grafana ohne Sonderfall greifen.
Zustand: __CURSOR der letzten Zeile in $STATE/cursor - keine Duplikate, kein
Verlust bei Neustart oder Rotation.
"""
import json
import os
import subprocess
import sys
UNITS = os.environ.get(
"LLAMASWAP_LOG_UNITS", "llama-swap.service,kalina-backends.service"
).split(",")
PRIORITY = os.environ.get("LLAMASWAP_LOG_PRIORITY", "info")
MAX_LINES = os.environ.get("LLAMASWAP_LOG_MAX_LINES", "800")
BUDGET = float(os.environ.get("LLAMASWAP_LOG_BUDGET", "12"))
SINCE = os.environ.get("LLAMASWAP_LOG_SINCE", "-5min")
STATE = os.path.expanduser(
os.environ.get("LLAMASWAP_LOG_STATE", "~/.local/state/llamaswap-logs")
)
CURSOR = os.path.join(STATE, "cursor")
def etag(wert):
"""Tag-Werte: Komma, Leerzeichen, Gleichheitszeichen escapen."""
for zeichen in ("\\", " ", ",", "="):
wert = wert.replace(zeichen, "\\" + zeichen)
return wert
def estr(wert):
"""String-Feld: Backslash und Anfuehrungszeichen escapen."""
return wert.replace("\\", "\\\\").replace('"', '\\"')
def journal_zeilen():
args = ["journalctl", "-o", "json", "--no-pager", "--no-hostname",
"-p", PRIORITY, "-n", MAX_LINES]
for unit in UNITS:
unit = unit.strip()
if unit:
args += ["--user-unit", unit]
try:
os.makedirs(STATE, exist_ok=True)
except OSError:
return []
if os.path.exists(CURSOR):
try:
with open(CURSOR, "r", encoding="utf-8") as fh:
letzter = fh.read().strip()
except OSError:
letzter = ""
args += ["--after-cursor", letzter] if letzter else ["--since", SINCE]
else:
args += ["--since", SINCE]
try:
ergebnis = subprocess.run(
args, capture_output=True, text=True, timeout=BUDGET
)
except (subprocess.TimeoutExpired, OSError):
return []
return [z for z in ergebnis.stdout.splitlines() if z.startswith("{")]
def main():
ausgabe, neuer_cursor = [], ""
for zeile in journal_zeilen():
try:
eintrag = json.loads(zeile)
except ValueError:
continue
neuer_cursor = eintrag.get("__CURSOR", neuer_cursor)
nachricht = eintrag.get("MESSAGE", "")
if not isinstance(nachricht, str) or not nachricht:
continue
unit = eintrag.get("_SYSTEMD_USER_UNIT") or eintrag.get("_SYSTEMD_UNIT") \
or eintrag.get("SYSLOG_IDENTIFIER") or "unknown"
ident = eintrag.get("SYSLOG_IDENTIFIER") or "unknown"
prio = str(eintrag.get("PRIORITY", "0"))
jhost = eintrag.get("_HOSTNAME") or "unknown"
ts = str(eintrag.get("__REALTIME_TIMESTAMP", ""))
ns = ts + "000" if ts.isdigit() else ""
if not ns:
continue
ausgabe.append(
'journal,unit=%s,ident=%s,priority=%s,jhost=%s message="%s" %s'
% (etag(unit), etag(ident), etag(prio), etag(jhost),
estr(nachricht), ns)
)
if neuer_cursor:
try:
with open(CURSOR + ".tmp", "w", encoding="utf-8") as fh:
fh.write(neuer_cursor)
os.replace(CURSOR + ".tmp", CURSOR)
except OSError:
pass
if ausgabe:
sys.stdout.write("\n".join(ausgabe) + "\n")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as fehler: # fail-open: nie den exec brechen
sys.stderr.write("llamaswap-logs: %s\n" % fehler)
sys.exit(0)
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""llama-swap + llama.cpp -> Prometheus-Text fuer Telegraf (inputs.exec).
Warum dieses Skript ueberhaupt:
llama-swap vergibt die Upstream-Ports DYNAMISCH (`${PORT}`, startPort 9340,
stabile Modell-ID-Reihenfolge) - ein statischer Scrape kann sie nicht
treffen. Ausserdem liegen die Token-Zahlen in zwei Quellen: llama.cpp
(/metrics, nur mit --metrics, kein Modell-Label) und llama-swaps eigenem
Statistik-Store (/api/metrics/stats, das die Upstream-Antworten selbst
auswertet und damit auch strata und sd-server abdeckt).
Ein exec-Input, das llama-swap selbst fragt, ist deshalb der einzige Weg
ohne Raten.
Quellen (alle nur lesend, jede mit eigenem Timeout, Fehler brechen nichts ab):
* GET /running laufende Modelle + Upstream-Adresse
* GET /v1/models alle Modell-IDs
* GET /api/metrics/stats?model=<id> Token-Summen je Modell
* GET http://<upstream>/metrics llama.cpp-Zaehler je Profil
Ausgabe: Prometheus-Textformat auf stdout.
* Keine erreichbare Quelle = keine Zeile fuer diese Quelle.
* Es wird nie ein Wert erfunden und nie ein Teil-Fehler als 0 gemeldet.
* Rueckgabewert ist immer 0, damit Telegraf den Lauf nicht als Fehler sieht.
Aufruf durch Telegraf (Nutzer-Instanz auf dem ROG):
[[inputs.exec]]
commands = ["/home/mm/bin/llamaswap-telemetrie.py"]
data_format = "prometheus"
timeout = "25s"
interval = "60s"
"""
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE = os.environ.get("LLAMASWAP_BASE", "http://192.168.188.184:9300")
TIMEOUT = float(os.environ.get("LLAMASWAP_TIMEOUT", "4"))
# Profile, die model-manager.sh auf festen Ports startet (Dauerlaeufer und
# Handstarts). Sie erscheinen NICHT in /running, wenn llama-swap sie nicht
# verwaltet - deshalb fest hinterlegt. Nicht erreichbare Ports kosten nur einen
# sofortigen ECONNREFUSED, keine Wartezeit.
FESTE_PORTS = {
8101: "chat",
8102: "embedding",
8103: "vision",
8104: "ocr",
8105: "rerank",
8107: "fast",
8108: "agent",
8109: "desktop",
}
METRIK_RE = re.compile(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{[^}]*\})?\s+(\S+)\s*$")
def hole(pfad, timeout=None):
"""GET gegen llama-swap. Gibt (status, bytes) zurueck - status 0 bei Fehler."""
url = pfad if pfad.startswith("http") else BASE + pfad
try:
with urllib.request.urlopen(url, timeout=timeout or TIMEOUT) as antwort:
return antwort.status, antwort.read()
except urllib.error.HTTPError as e:
return e.code, b""
except Exception:
return 0, b""
def hole_json(pfad):
status, rohdaten = hole(pfad)
if status != 200 or not rohdaten:
return None
try:
return json.loads(rohdaten)
except ValueError:
return None
def name_saeubern(name):
"""llama.cpp liefert `llamacpp:prompt_tokens_total` (Doppelpunkt erlaubt,
aber Prometheus reserviert ihn fuer Recording-Regeln) -> Unterstrich."""
return name.replace(":", "_")
def doppelpunkt_label_frei(labels):
"""Label-String normalisieren: fuehrende/abschliessende Klammern entfernen."""
if not labels:
return ""
labels = labels.strip()
if labels.startswith("{") and labels.endswith("}"):
return labels[1:-1]
return ""
def upstream_kandidaten():
"""{port: modell} aus /running plus die festen model-manager-Ports."""
kandidaten = dict(FESTE_PORTS)
running = hole_json("/running")
if isinstance(running, dict):
for eintrag in running.get("running", []) or []:
modell = eintrag.get("model") or ""
proxy = eintrag.get("proxy") or ""
m = re.search(r":(\d+)\s*$", proxy)
if m and modell:
# /running gewinnt: llama-swap kennt den echten Modellnamen.
kandidaten[int(m.group(1))] = modell
return kandidaten
def llama_cpp_zeilen(port, modell):
"""Prometheus-Zeilen eines llama.cpp-Upstreams, mit model-Label ergaenzt."""
status, rohdaten = hole("http://192.168.188.184:%d/metrics" % port, timeout=2)
if status != 200 or not rohdaten:
return [], []
text = rohdaten.decode("utf-8", "replace")
if "llamacpp" not in text: # kein llama.cpp (strata, sd-server)
return [], []
typen, zeilen = {}, []
for zeile in text.splitlines():
zeile = zeile.strip()
if not zeile:
continue
if zeile.startswith("# TYPE"):
teile = zeile.split()
if len(teile) >= 4:
typen[name_saeubern(teile[2])] = teile[3]
continue
if zeile.startswith("#"):
continue
m = METRIK_RE.match(zeile)
if not m:
continue
name = name_saeubern(m.group(1))
labels = doppelpunkt_label_frei(m.group(2))
wert = m.group(3)
if wert in ("NaN", "+Inf", "-Inf"):
continue
try:
float(wert)
except ValueError:
continue
neue_labels = ('model="%s"' % modell) + ("," + labels if labels else "")
zeilen.append((name, neue_labels, wert))
return typen, zeilen
def aktivitaets_zeilen():
"""Token-Summen je Modell aus llama-swaps Statistik-Store."""
modelle = hole_json("/v1/models")
if not isinstance(modelle, dict):
return [], []
ids = [m.get("id") for m in modelle.get("data", []) or [] if m.get("id")]
felder = [
("total_requests", "llamaswap_activity_requests_total"),
("total_input_tokens", "llamaswap_activity_input_tokens_total"),
("total_output_tokens", "llamaswap_activity_output_tokens_total"),
("total_cache_tokens", "llamaswap_activity_cache_tokens_total"),
]
zeilen = []
for modell in ids:
stats = hole_json("/api/metrics/stats?model=%s" % urllib.parse.quote(modell))
if not isinstance(stats, dict):
continue
for schluessel, metrik in felder:
wert = stats.get(schluessel)
if isinstance(wert, (int, float)) and not isinstance(wert, bool):
zeilen.append((metrik, 'model="%s"' % modell, "%d" % wert))
return zeilen
def main():
typen = {}
zeilen = []
kandidaten = upstream_kandidaten()
for port, modell in sorted(kandidaten.items()):
t, z = llama_cpp_zeilen(port, modell)
typen.update(t)
zeilen.extend(z)
aktivitaet = aktivitaets_zeilen()
for metrik, _, _ in aktivitaet:
typen[metrik] = "counter"
zeilen.extend(aktivitaet)
if not zeilen:
# Nichts erreichbar: lieber gar nichts ausgeben als Nullen.
return 0
ausgabe = []
for name in sorted({n for n, _, _ in zeilen}):
typ = typen.get(name, "gauge")
ausgabe.append("# TYPE %s %s" % (name, typ))
for n, labels, wert in zeilen:
if n != name:
continue
ausgabe.append("%s{%s} %s" % (n, labels, wert))
sys.stdout.write("\n".join(ausgabe) + "\n")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as fehler: # fail-open: nie den exec brechen
sys.stderr.write("llamaswap-telemetrie: %s\n" % fehler)
sys.exit(0)
+160
View File
@@ -0,0 +1,160 @@
#!/bin/bash
# raw-log-backfill.sh — Rohlogs eines KeyHelp-Hosts fuer den LOKI-Import sammeln.
#
# Warum es dieses Skript gibt:
# Der Prometheus-Archiv-Import verdichtet jede Logzeile auf Stunde/Domain/
# Status und VERWIRFT dabei Referrer, User-Agent und Client-IP. Fragen nach
# Herkunft ("woher kommen die Besucher") liessen sich damit nicht
# rueckwirkend beantworten. Lokis Rohzeilen koennen es — aber die
# Live-Sammlung reicht nur bis zur Installation zurueck.
# Dieses Skript holt die Historie in eine Datei, die Loki aufnehmen kann.
#
# DIESES SKRIPT AENDERT NICHTS AM LAUFENDEN DIENST. Es liest nur Logdateien,
# es stoppt nichts, es loescht nichts.
#
# AUFRUF (auf dem Host, als root):
# curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/scripts/raw-log-backfill.sh -o /root/raw-backfill.sh
# bash /root/raw-backfill.sh # MESSEN (aendert nichts)
# bash /root/raw-backfill.sh --collect # Rohdatei bauen
# bash /root/raw-backfill.sh --collect --since 2025-01-01
# bash /root/raw-backfill.sh --collect --tarball /root/apache-archive/apache-archive.tar.gz
#
# Am Ende steht der exakte Befehl fuer den imac.
set -eu
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
OUT_DIR="${OUT_DIR:-/root/apache-archive}"
NODE="${NODE:-$(hostname -f 2>/dev/null || hostname)}"
COLLECT=0
SINCE=""
UNTIL=""
TARBALL=""
ROOTS="/var/log/apache2 /home/users"
while [ $# -gt 0 ]; do
case "$1" in
--collect) COLLECT=1 ;;
--since) SINCE="${2:?Datum fehlt}"; shift ;;
--until) UNTIL="${2:?Datum fehlt}"; shift ;;
--tarball) TARBALL="${2:?Pfad fehlt}"; shift ;;
--node) NODE="${2:?Name fehlt}"; shift ;;
--roots) ROOTS="${2:?Pfade fehlen}"; shift ;;
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
esac
shift
done
log() { printf '\033[1;34m[raw-backfill]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[raw-backfill] WARNUNG:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[raw-backfill] FEHLER:\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Bitte als root ausfuehren (die Logs liegen unter /home/users mit 0700)."
mkdir -p "$OUT_DIR"
COLLECTOR="$OUT_DIR/raw-log-collect.py"
if [ -r "$COLLECTOR" ] && [ -n "${SKIP_FETCH:-}" ]; then
log "Collector lokal uebernommen: $COLLECTOR"
else
log "Lade Collector vom Repo …"
curl -fsSL -m 30 "$REPO_RAW/scripts/raw-log-collect.py" -o "$COLLECTOR" \
|| die "raw-log-collect.py konnte nicht geladen werden."
fi
python3 -c "import sys; sys.exit(0)" || die "python3 fehlt auf diesem Host."
# --------------------------------------------------------------------------
# 1) MESSEN — was ist ueberhaupt da? (aendert nichts)
# --------------------------------------------------------------------------
log "Bestandsaufnahme der Apache-Logs (liest nur) …"
FILES=$(find $ROOTS -name 'access.log*' -o -name 'error.log*' 2>/dev/null | wc -l)
GZ=$(find $ROOTS -name '*.log*.gz' 2>/dev/null | wc -l)
RAW_BYTES=$(find $ROOTS -name 'access.log*' -o -name 'error.log*' 2>/dev/null -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}')
log " Logdateien: $FILES (davon komprimiert: $GZ)"
log " Rohgroesse: $(( RAW_BYTES / 1024 / 1024 )) MB"
log " Aelteste Datei: $(find $ROOTS -name 'access.log*' -printf '%TY-%Tm-%Td %p\n' 2>/dev/null | sort | head -1 | cut -c1-10)"
if [ -n "$TARBALL" ] && [ -r "$TARBALL" ]; then
log " Tarball: $TARBALL ($(( $(stat -c%s "$TARBALL") / 1024 / 1024 )) MB)"
log " Rohlogs im Tar: $(tar -tzf "$TARBALL" 2>/dev/null | grep -c 'raw/.*log' || echo 0) Dateien"
fi
if [ "$COLLECT" -eq 0 ]; then
echo
log "Das war NUR die Messung — es wurde nichts geschrieben."
echo " Weiter mit: bash $0 --collect${SINCE:+ --since $SINCE}"
echo " Optional zusaetzlich Rohlogs aus dem Archiv-Tarball:"
echo " --tarball $OUT_DIR/apache-archive.tar.gz"
exit 0
fi
# --------------------------------------------------------------------------
# 2) SAMMELN
# --------------------------------------------------------------------------
ARGS="--node $NODE --out $OUT_DIR --roots $ROOTS"
[ -n "$SINCE" ] && ARGS="$ARGS --since $SINCE"
[ -n "$UNTIL" ] && ARGS="$ARGS --until $UNTIL"
[ -n "$TARBALL" ] && [ -r "$TARBALL" ] && ARGS="$ARGS --include-tarball $TARBALL"
log "Sammle Rohlogs … (bei viel Historie dauert das einige Minuten)"
# shellcheck disable=SC2086
python3 "$COLLECTOR" $ARGS 2>&1 | tee "$OUT_DIR/collect.log"
RC=${PIPESTATUS[0]}
[ "$RC" -eq 0 ] || die "Collector brach ab (Code $RC). Siehe $OUT_DIR/collect.log"
PART=$(ls -t "$OUT_DIR"/loki-raw-*/raw.tsv 2>/dev/null | head -1)
STATE=$(ls -t "$OUT_DIR"/loki-raw-*/state.json 2>/dev/null | head -1)
[ -n "$PART" ] && [ -r "$PART" ] || die "Keine Rohdatei gefunden."
[ -n "$STATE" ] && [ -r "$STATE" ] || die "Keine Zustandsdatei gefunden."
LINES=$(wc -l < "$PART")
SIZE=$(( $(stat -c%s "$PART") / 1024 / 1024 ))
log "Rohdatei: $PART"
log " $LINES Zeilen, $SIZE MB"
# Gegenprobe: stimmt die Zeilenzahl mit der Zustandsdatei ueberein?
EXPECT=$(python3 -c "import json,sys; print(json.load(open('$STATE'))['lines'])")
if [ "$LINES" != "$EXPECT" ]; then
warn "Zeilenzahl weicht ab: Datei=$LINES, Auftrag=$EXPECT — bitte pruefen."
else
log " Gegenprobe: Zeilenzahl stimmt mit dem Auftrag ueberein ($LINES)."
fi
# --------------------------------------------------------------------------
# 3) Uebergabe vorbereiten
#
# Der Collector laeuft als root (die Logs liegen unter /home/users mit 0700),
# seine Ausgabe gehoert damit ebenfalls root. `scp` als normaler Nutzer kaeme
# nicht an die Datei — und ein scp als root scheitert oft am SSH-Schluessel.
# Deshalb die Dateien fuer die Uebergabe lesbar machen und an eine Stelle
# legen, die beide erreichen.
# --------------------------------------------------------------------------
STAGE=/tmp/rawlog-upload
mkdir -p "$STAGE"
cp "$PART" "$STAGE/raw.tsv"
cp "$STATE" "$STAGE/state.json"
chmod 644 "$STAGE/raw.tsv" "$STAGE/state.json"
log "Uebergabedateien bereit: $STAGE/ (644, fuer scp als normaler Nutzer)"
echo
log "NAECHSTER SCHRITT — diese Zeilen auf dem imac ausfuehren:"
echo
echo " # 1) Rohdatei und Auftrag uebertragen (als normaler Nutzer moeglich)"
echo " # ACHTUNG: nach /data/tmp, NICHT nach /tmp!"
echo " # Auf dem imac ist /tmp ein tmpfs mit 15 GB (also RAM). Eine grosse"
echo " # Rohdatei wuerde dort den Arbeitsspeicher belegen und Loki stoeren."
echo " ssh mm@100.83.134.46 'mkdir -p /data/tmp'"
echo " scp $STAGE/raw.tsv $STAGE/state.json mm@100.83.134.46:/data/tmp/"
echo
echo " # 2) Einlesen (der Import laeuft LOKAL auf dem imac — kein Token noetig)"
echo " ssh mm@100.83.134.46 \\"
echo " 'python3 /usr/local/bin/loki-raw-import.py \\"
echo " --file /tmp/$(basename "$PART") --state /tmp/$(basename "$STATE") \\"
echo " --url http://127.0.0.1:3100'"
echo
echo " # Vorher pruefen (empfohlen, aendert nichts):"
echo " ssh mm@100.83.134.46 \\"
echo " 'python3 /usr/local/bin/loki-raw-import.py \\"
echo " --file /tmp/$(basename "$PART") --state /tmp/$(basename "$STATE") \\"
echo " --url http://127.0.0.1:3100 --dry-run'"
echo
log "Danach im Grafana pruefen: {imported=\"1\"} (Dashboard: Weblogs-Analyse)"
warn "Das Skript selbst schreibt NICHTS nach Loki — der Import ist ein eigener Schritt."
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""Rohlogs (Apache access+error) eines Hosts in eine importierbare Datei schreiben.
Erzeugt eine DREISPALTIGE, nach Zeitstempel sortierbare Datei:
<ts_ns> <TAB> <labels-json> <TAB> <rohzeile>
Warum dieses Format:
* `ts_ns` in Spalte 1 -> GNU `sort -k1,1n` kann mit `-S` und `-T` EXTERN
sortieren. 40 Mio Zeilen (~12 GB) passen nicht in den RAM des imac; ein
Python-`sorted()` wuerde dort sterben. Externer Merge-Sort skaliert.
* Nach Zeitstempel aufsteigend sortiert ist PFLICHT, nicht Kosmetik:
Loki erlaubt kein Out-of-Order. Ein aelterer Eintrag nach einem neueren
wird mit "entry too far behind" abgelehnt (empirisch bestaetigt).
* Die Rohzeile bleibt UNVERAENDERT (nur die erste Tab-Trennung wird
geprueft). Das `message="..."`-Format, das Telegraf live schreibt, setzt
der Importer — die Kollektor-Ausgabe bleibt dadurch pruefbar roh.
Dieses Skript aendert NICHTS am laufenden Dienst und loescht nichts.
AUFRUF (auf dem Host, als root):
python3 raw-log-collect.py --node x14.idx.wf-keyhelp --out /root/apache-archive
python3 raw-log-collect.py --node x14 --out /root/apache-archive \
--since 2025-01-01 --include-tarball /root/apache-archive/apache-archive.tar.gz
"""
from __future__ import annotations
import argparse
import gzip
import json
import os
import re
import sys
import tarfile
import tempfile
from collections import Counter
from datetime import datetime, timezone
# --------------------------------------------------------------------------
# Zeitstempel-Formate in Apache-Logs.
#
# access (Combined/Common, KeyHelp-Standard): [14/Sep/2026:17:00:18 +0200]
# error (Apache-Standard OHNE Zeitzone): [Mon Sep 14 17:00:18.123456 2026]
# error (alternative Variante MIT Zeitzone): [14/Sep/2026:17:00:18 +0200]
#
# OHNE das reine Error-Format landen Error-Zeilen still auf hour="unknown"
# und werden uebersprungen — genau dieser Fehler ist schon einmal passiert.
# --------------------------------------------------------------------------
TS_ACCESS = re.compile(r"\[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2})\s*([+-]\d{4})?\]")
TS_ERROR_NTZ = re.compile(
r"\[(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+\d{4})\]")
TS_BRACKET = re.compile(r"\[([^\]]+)\]")
MONTHS = {m: i + 1 for i, m in enumerate(
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])}
def parse_ts(line: str) -> int | None:
"""Liefert Unix-Nanosekunden oder None, wenn kein Zeitstempel erkennbar."""
m = TS_ACCESS.search(line)
if m:
base, off = m.group(1), m.group(2)
try:
dt = datetime.strptime(base, "%d/%b/%Y:%H:%M:%S")
if off:
sign = 1 if off[0] == "+" else -1
mins = int(off[1:3]) * 60 + int(off[3:5])
dt = dt.replace(tzinfo=timezone.utc) if False else dt
# Offset anwenden: lokale Zeit -> UTC
from datetime import timedelta
dt = dt - timedelta(minutes=sign * mins)
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1e9)
except ValueError:
pass
m = TS_ERROR_NTZ.search(line)
if m:
raw = re.sub(r"\s+", " ", m.group(1)).strip()
for fmt in ("%a %b %d %H:%M:%S.%f %Y", "%a %b %d %H:%M:%S %Y"):
try:
dt = datetime.strptime(raw, fmt)
# Apache schreibt Error-Logs in LOKALER Zeit (hier CEST, +02:00).
# Ohne Offset-Angabe wird die Zeile sonst um 2 h verschoben
# einsortiert. Der Aufrufer kann den Offset setzen.
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1e9)
except ValueError:
continue
return None
def classify(path: str) -> dict:
"""Labels aus dem Dateipfad ableiten — identisch zum Live-Schema von Telegraf."""
kind = "error" if "error" in os.path.basename(path) else "access"
# Domain aus dem Pfad: /home/users/<user>/logs/<domain>/access.log
m = re.search(r"/logs/([^/]+)/", path)
domain = m.group(1) if m else ""
return {"kind": kind, "domain": domain}
def iter_lines(path: str):
"""Zeilen einer Logdatei liefern — auch aus .gz."""
if path.endswith(".gz"):
with gzip.open(path, "rt", errors="replace") as fh:
for ln in fh:
yield ln.rstrip("\n")
else:
with open(path, "rt", errors="replace") as fh:
for ln in fh:
yield ln.rstrip("\n")
def collect_from_dir(root: str):
"""Alle access.log*/error.log* unterhalb von root finden."""
for dirpath, _dirnames, filenames in os.walk(root):
for fn in filenames:
if fn.startswith(("access.log", "error.log")):
yield os.path.join(dirpath, fn)
def collect_from_tarball(tar_path: str, stats: Counter | None = None):
"""Rohlogs aus einem Archiv-Tarball (Pfade unter raw/).
Die Tarballs des Backfills enthalten raw/<absoluter-pfad-ohne-fuehrenden-slash>.
FALLE (real, 17 % Datenverlust): die `.gz`-Dateien IM Tarball sind selbst
noch gzip-komprimiert. `tarfile` entpackt nur die aeussere Ebene — wer den
Member-Inhalt direkt dekodiert, liest das gzip-Magic (\x1f\x8b) als Text und
verliert jede Zeile dieser Dateien. Gemessen am x14-Tarball: 68.975 von
404.825 Zeilen (17 %) fielen so aus, verteilt auf genau die `.gz`-Member
(access.log.2/.3/.4.gz, error.log.2/.3/.4.gz). Deshalb: Member mit `.gz`
ein zweites Mal entpacken.
"""
gz_ok = gz_fail = 0
with tarfile.open(tar_path, "r:gz") as tf:
for member in tf.getmembers():
if not member.isfile():
continue
name = member.name
if "/raw/" not in name and not name.startswith("raw/"):
continue
base = os.path.basename(name)
if not base.startswith(("access.log", "error.log")):
continue
fh = tf.extractfile(member)
if fh is None:
continue
logpath = "/" + name.split("raw/", 1)[1] if "raw/" in name else name
data = fh.read()
text = None
if name.endswith(".gz"):
try:
text = gzip.decompress(data).decode("utf-8", "replace")
gz_ok += 1
except Exception: # noqa: BLE001
gz_fail += 1
text = data.decode("utf-8", "replace")
else:
text = data.decode("utf-8", "replace")
for raw in text.splitlines():
yield logpath, raw
if stats is not None:
stats["gz_member_ok"] += gz_ok
stats["gz_member_fail"] += gz_fail
def build_labels(node: str, logpath: str, imported: str) -> dict:
"""Label-Set wie das Live-Schema von Telegraf, plus imported=1.
Unterschiede zum Live-Schema (bewusst):
* `imported="1"` — trennt die Historie vom Live-Store. Nur fuer diese
Streams gilt die lange Retention (retention_stream in loki.yml).
Ohne das Label wuerde der Compactor die Historie nach 90 Tagen
loeschen.
* `kind`, `domain` — aus dem Pfad abgeleitet. Erlaubt Queries ohne
teure Regex-Arbeit auf `logfile` und macht Dashboards schneller.
"""
meta = classify(logpath)
return {
"source": "apache",
"host": node,
"node": node,
"logfile": logpath,
"service_name": "unknown_service",
"detected_level": "unknown",
"imported": imported,
"kind": meta["kind"],
"domain": meta["domain"] or "unknown",
}
def main() -> int:
ap = argparse.ArgumentParser(
description="Apache-Rohlogs fuer den Loki-Import aufbereiten")
ap.add_argument("--node", required=True, help="Node-Name (host-Label)")
ap.add_argument("--out", default="/root/apache-archive",
help="Ausgabeverzeichnis")
ap.add_argument("--roots", nargs="*",
default=["/var/log/apache2", "/home/users"],
help="Wurzelverzeichnisse der Logs")
ap.add_argument("--include-tarball", action="append", default=[],
help="Zusaetzlich Rohlogs aus diesem .tar.gz lesen")
ap.add_argument("--since", default=None,
help="Nur Zeilen ab diesem Datum (JJJJ-MM-TT)")
ap.add_argument("--until", default=None, help="Nur Zeilen bis (JJJJ-MM-TT)")
ap.add_argument("--tz-offset", type=int, default=2,
help="UTC-Offset der Error-Logs in Stunden (Default 2 = CEST)")
ap.add_argument("--limit-lines", type=int, default=0,
help="Nach N Zeilen abbrechen (fuer Tests)")
args = ap.parse_args()
since_ns = (int(datetime.strptime(args.since, "%Y-%m-%d")
.replace(tzinfo=timezone.utc).timestamp() * 1e9)
if args.since else None)
until_ns = (int(datetime.strptime(args.until, "%Y-%m-%d")
.replace(tzinfo=timezone.utc).timestamp() * 1e9)
if args.until else None)
os.makedirs(args.out, exist_ok=True)
tmpdir = tempfile.mkdtemp(prefix="loki-raw-", dir=args.out)
part = os.path.join(tmpdir, "raw.tsv")
state = os.path.join(tmpdir, "state.json")
files: list[str] = []
for r in args.roots:
files.extend(collect_from_dir(r))
files = sorted(files)
print(f"[collect] {len(files)} Logdateien unter {args.roots} gefunden")
stats = Counter()
n_ok = n_nots = n_skip = 0
examples: list[str] = []
with open(part, "w", encoding="utf-8") as out:
sources = ((p, iter_lines(p)) for p in files)
for path, lines in sources:
lab = build_labels(args.node, path, "1")
lab_s = json.dumps(lab, ensure_ascii=False, separators=(",", ":"))
for raw in lines:
if not raw.strip():
continue
ts = parse_ts(raw)
if ts is None:
n_nots += 1
if len(examples) < 5:
examples.append(raw[:120])
continue
if since_ns and ts < since_ns:
n_skip += 1
continue
if until_ns and ts >= until_ns:
n_skip += 1
continue
# Error-Logs ohne Zeitzone sind LOKALzeit -> nach UTC schieben
if lab["kind"] == "error" and TS_ERROR_NTZ.search(raw) \
and not TS_ACCESS.search(raw):
ts -= args.tz_offset * 3600 * 10**9
raw = raw.replace("\t", " ")
out.write(f"{ts}\t{lab_s}\t{raw}\n")
n_ok += 1
stats[lab["kind"]] += 1
if args.limit_lines and n_ok >= args.limit_lines:
break
if args.limit_lines and n_ok >= args.limit_lines:
break
# Tarballs zuletzt (sie liegen historisch VOR den Live-Dateien).
for tb in args.include_tarball:
print(f"[collect] Tarball: {tb}")
for logpath, raw in collect_from_tarball(tb, stats):
if args.limit_lines and n_ok >= args.limit_lines:
break
if not raw.strip():
continue
ts = parse_ts(raw)
if ts is None:
n_nots += 1
if len(examples) < 5:
examples.append(f"[{os.path.basename(logpath)}] "
f"{raw[:110]}")
continue
if since_ns and ts < since_ns:
n_skip += 1
continue
if until_ns and ts >= until_ns:
n_skip += 1
continue
lab = build_labels(args.node, logpath, "1")
if lab["kind"] == "error" and TS_ERROR_NTZ.search(raw) \
and not TS_ACCESS.search(raw):
ts -= args.tz_offset * 3600 * 10**9
lab_s = json.dumps(lab, ensure_ascii=False, separators=(",", ":"))
out.write(f"{ts}\t{lab_s}\t{raw.replace(chr(9), ' ')}\n")
n_ok += 1
stats[lab["kind"]] += 1
print(f"[collect] {n_ok} Zeilen geschrieben (access={stats['access']} "
f"error={stats['error']})")
if stats["gz_member_ok"] or stats["gz_member_fail"]:
print(f"[collect] gzip-Member im Tarball entpackt: "
f"{stats['gz_member_ok']}"
+ (f", FEHLGESCHLAGEN: {stats['gz_member_fail']}"
if stats["gz_member_fail"] else ""))
if n_nots:
print(f"[collect] WARNUNG: {n_nots} Zeilen OHNE erkennbaren Zeitstempel "
f"wurden uebersprungen (kein stilles Verwerfen):")
for e in examples:
print(f" {e}")
if n_skip:
print(f"[collect] {n_skip} Zeilen ausserhalb des Zeitfensters "
f"(since={args.since} until={args.until})")
if n_ok == 0:
print("[collect] FEHLER: keine Zeilen gesammelt — Abbruch.", file=sys.stderr)
return 1
# Groesse + Sortierung festhalten, damit der Import den Bestand beweisen kann.
size = os.path.getsize(part)
with open(state, "w") as fh:
json.dump({"node": args.node, "lines": n_ok,
"access": stats["access"], "error": stats["error"],
"without_ts": n_nots, "skipped": n_skip,
"since": args.since, "until": args.until,
"bytes": size, "tz_offset": args.tz_offset}, fh, indent=2)
print(f"[collect] Rohdatei: {part} ({size/1e6:.1f} MB, unsortiert)")
print(f"[collect] Zustand: {state}")
print()
print("[collect] NAECHSTER SCHRITT — von Hand auf dem imac (nicht hier):")
print(f" scp {part} mm@100.83.134.46:/tmp/")
print(f" scp {state} mm@100.83.134.46:/tmp/")
return 0
if __name__ == "__main__":
sys.exit(main())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# x22-log-archive.sh — Alte Apache-Logs von x22 einsammeln, parsen, ins Archiv schieben.
#
# WARUM ES DIESES SKRIPT GIBT
# Alte Logs sollen dauerhaft auswertbar bleiben. Der Weg dorthin hat zwei Fallen,
# die beide still zuschlagen:
# 1. Ein Import in die PRODUKTIONS-TSDB kann die Zeitreferenz verschieben und
# damit alle Live-Daten als "too old" verwerfen. Deshalb geht der Import
# ausschliesslich in die getrennte Archiv-Instanz (:9091).
# 2. OpenMetrics-Zeitstempel sind SEKUNDEN. Millisekunden ergeben Bloecke im
# Jahr 58.662, die Prometheus verwirft — die Abfrage bleibt dann leer.
# Das Archiver-Skript prueft Punkt 2 selbst (Einheiten-Waechter) und bricht ab.
#
# AUFRUF auf x22 (als root):
# curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/scripts/x22-log-archive.sh -o /root/x22-archive.sh
# bash /root/x22-archive.sh # Messen (nichts wird veraendert)
# bash /root/x22-archive.sh --backfill # Logs parsen und Archiv-Tarball bauen
#
# Das Skript AENDERT NICHTS am laufenden Telegraf und importiert nichts selbst —
# es liefert eine Datei, die auf dem imac ins Archiv geschoben wird.
set -eu
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
OUT_DIR="${OUT_DIR:-/root/apache-archive}"
NODE="${NODE:-x22.idx.wf-keyhelp}"
BACKFILL=0
[ "${1:-}" = "--backfill" ] && BACKFILL=1
log() { printf '\033[1;34m[x22-archiv]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[x22-archiv] WARNUNG:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[x22-archiv] FEHLER:\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Bitte als root ausfuehren."
# --------------------------------------------------------------------------
# 1) Bestand messen — MESSEN, NICHT RATEN
# --------------------------------------------------------------------------
log "Bestandsaufnahme der Apache-Logs …"
mkdir -p /tmp/x22-archiv
curl -fsSL "$REPO_RAW/scripts/apache-log-survey.py" -o /tmp/x22-archiv/survey.py \
|| die "survey.py konnte nicht geladen werden ($REPO_RAW/scripts/apache-log-survey.py)"
python3 /tmp/x22-archiv/survey.py --node "$NODE" 2>&1 | tee /tmp/x22-archiv/bestand.txt
if [ "$BACKFILL" -eq 0 ]; then
echo
log "Das war nur die Messung — es wurde nichts veraendert."
echo " Fuer die Archivierung: bash $0 --backfill"
echo " Bestand: /tmp/x22-archiv/bestand.txt"
exit 0
fi
# --------------------------------------------------------------------------
# 2) Archiv bauen
# --------------------------------------------------------------------------
log "Archiver laden …"
curl -fsSL "$REPO_RAW/scripts/apache-log-archive.py" -o /tmp/x22-archiv/archive.py \
|| die "apache-log-archive.py konnte nicht geladen werden."
log "Logs parsen und OpenMetrics erzeugen (kann bei viel Historie dauern) …"
python3 /tmp/x22-archiv/archive.py --node "$NODE" --out "$OUT_DIR" --include-raw 2>&1 | tail -20
# --------------------------------------------------------------------------
# 3) Selbst pruefen, bevor irgendetwas weitergereicht wird
# --------------------------------------------------------------------------
OM="$OUT_DIR/apache-archive.openmetrics"
[ -s "$OM" ] || die "Keine Ausgabe erzeugt: $OM fehlt oder ist leer."
log "Selbstpruefung der Ausgabe"
python3 - "$OM" <<'PY'
import sys, time
path = sys.argv[1]
lines = [l for l in open(path) if l and not l.startswith("#")]
if not lines:
print(" FEHLER: keine Datenpunkte"); sys.exit(1)
ts = sorted(int(l.rsplit(" ", 1)[1]) for l in lines)
now = int(time.time())
print(f" Datenpunkte: {len(lines)}")
print(f" Zeitraum: {time.strftime('%d.%m.%Y %H:%M', time.gmtime(ts[0]))}"
f" .. {time.strftime('%d.%m.%Y %H:%M', time.gmtime(ts[-1]))} UTC")
print(f" Einheit: Sekunden (Plausibilitaet geprueft)")
# Einheiten-Wache: alles nach 2100 ist kein Sekundenwert
if ts[-1] > 4102444800:
print(" FEHLER: Zeitstempel nach dem Jahr 2100 -> Millisekunden statt Sekunden"); sys.exit(1)
# Zukunft pruefen: das ist der Fehler, der die Produktion kosten kann
zukunft = [t for t in ts if t > now + 300]
if zukunft:
print(f" WARNUNG: {len(zukunft)} Punkte liegen in der ZUKUNFT "
f"(max {time.strftime('%d.%m.%Y %H:%M', time.gmtime(zukunft[-1]))}).")
print(" Diese Punkte stammen aus einer falschen Systemuhr oder falschen Logs.")
else:
print(" Zukunfts-Check: OK (kein Punkt nach jetzt)")
PY
TAR="$OUT_DIR/apache-archive.tar.gz"
[ -s "$TAR" ] && log "Tarball: $TAR ($(du -h "$TAR" | cut -f1))"
# --------------------------------------------------------------------------
# 4) Uebergabe
# --------------------------------------------------------------------------
cat <<EOF
============================================================
FERTIG — naechster Schritt auf dem imac (nichts weiter noetig auf x22)
============================================================
Von hier aus die Ausgabe auf den imac holen und ins ARCHIV einspielen
(NICHT in die Produktions-TSDB):
# 1) Uebertragen (von x22 aus, im NetBird)
scp $OM mm@100.83.134.46:/tmp/apache-archive.openmetrics
# 2) Auf dem imac: in die ARCHIV-Instanz einspielen
# --file ist Pflicht. Ohne diese Option wuerde das Import-Skript den
# Produktions-Pfad anfassen — dagegen ist es inzwischen gesperrt.
ssh mm@192.168.188.177 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/apache-archive.openmetrics'
Wichtig: der Import prueft zuerst auf Zukunfts-Zeitstempel, legt ein Manifest
an (Schutz gegen Doppel-Import) und schreibt ausschliesslich ins Archiv. Ein
zweiter Lauf mit derselben Datei wird uebersprungen und sagt das auch.
Bricht er ab, ist das der Schutz, nicht ein Defekt — Ursache in der Ausgabe
pruefen.
Das Skript fuehrt den Import bewusst NICHT selbst aus: die Produktions-TSDB
liegt auf einem anderen Host, und ein automatischer Import von einer Node aus
waere genau der Weg, der am 14.09. die Historie gekostet hat.
EOF
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# x22-log-backfill.sh — Kompatibilitaets-Alias.
# Die Logik liegt in keyhelp-log-backfill.sh (fuer alle KeyHelp-Hosts).
# Dieser Name bleibt bestehen, damit bereits verteilte Aufrufe weiterlaufen.
export NODE="${NODE:-x22.idx.wf-keyhelp}"
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
exec curl -fsSL -m 30 "$REPO_RAW/scripts/keyhelp-log-backfill.sh" -o /tmp/.kh-backfill.sh \
&& NODE="$NODE" REPO_RAW="$REPO_RAW" bash /tmp/.kh-backfill.sh "$@"
@@ -0,0 +1,32 @@
# =============================================================================
# Telegraf als CONTAINER auf einem Knoten OHNE sudo.
#
# Fuer Knoten mit System-Telegraf ist install.sh der richtige Weg; hier genuegt
# die Docker-Gruppe, weil nur die fertige .prom-Datei gelesen und per Remote
# Write gepusht wird. Kein /hostfs, kein docker.sock, kein privileged -- nichts
# davon braucht eine dateibasierte App-Metrik.
#
# Werte kommen aus der Umgebung (Telegraf expandiert ${...} beim Laden), damit
# kein Geheimnis und keine IP doppelt im Repo steht.
# =============================================================================
services:
telegraf:
image: telegraf:1.39 # gleiche Version wie der Collector (imac)
container_name: telegraf-archibald
restart: unless-stopped
# host-Netz: derselbe Weg, den ein curl auf dem Host zum Collector nimmt.
# Ueber eine Bridge kaeme ein zusaetzlicher NAT/forward-Hop hinzu, der in
# NetBird-Firewall-Regeln haengenbleiben kann.
network_mode: host
environment:
NODE_NAME: archibald
# Direkter Mesh-Weg, kein Reverse-Proxy: der Collector laeuft mit
# --web.enable-remote-write-receiver und OHNE web.config.file, also ohne
# Auth. Ueber den Proxy-Weg waere ein Bearer-Token noetig.
PROM_URL: http://100.83.134.46:9090/api/v1/write
volumes:
- ./telegraf.conf:/etc/telegraf/telegraf.conf:ro
# VERZEICHNIS-Mount, bewusst kein Datei-Mount: der Cron-Exporteur ersetzt
# die .prom-Datei atomar per mv -- ein Datei-Mount haette danach weiter
# den alten Inode und lieferte still veraltete Werte.
- /home/mm/abx-telemetrie:/var/lib/abx-prom:ro
+44
View File
@@ -0,0 +1,44 @@
# =============================================================================
# Telegraf-Konfiguration fuer einen CONTAINER-Knoten ohne sudo.
#
# Variablen kommen aus der Container-Umgebung (docker-compose `environment`),
# NICHT aus /etc/default/telegraf -- das gibt es auf solchen Knoten nicht.
# =============================================================================
[global_tags]
node = "${NODE_NAME}"
[agent]
# Der Exporteur schreibt einmal pro Minute; 60 s Abtastung ist das Maximum,
# das keine Werte doppelt sieht. Jitter, damit Cron-Schreiben und Abtastung
# nicht im Gleichtakt kollidieren.
interval = "60s"
flush_interval = "60s"
collection_jitter = "5s"
hostname = "${NODE_NAME}"
omit_hostname = false
# Reserve fuer Ausfaelle: Telegraf puffert nur, solange der Push scheitert.
metric_buffer_limit = 200000
# Der Cron-Exporteur schreibt die .prom-Datei atomar (mv).
# data_format = "prometheus" ist PFLICHT: das Skript besitzt seine Metriknamen
# schon. Mit "influx" bekaeme jede Serie ein _value-Suffix (oder der Parser
# scheiterte ganz) und jedes Panel bliebe leer, ohne Fehlermeldung.
[[inputs.file]]
files = ["/var/lib/abx-prom/abx.prom"]
data_format = "prometheus"
# ---- Metriken -> Prometheus Remote Write (Collector imac) ----
[[outputs.http]]
url = "${PROM_URL}"
data_format = "prometheusremotewrite"
timeout = "60s"
# Ein 400 ist PERMANENT: dieselbe Nutzlast liefert dieselbe Antwort. Ohne
# diese Liste bleibt der abgelehnte Batch als FIFO-Kopf stehen, wird endlos
# wiederholt und blockiert jede dahinter gesammelte Serie.
non_retryable_statuscodes = [400, 409, 413, 422]
[outputs.http.headers]
Content-Type = "application/x-protobuf"
Content-Encoding = "snappy"
X-Prometheus-Remote-Write-Version = "0.1.0"
# Kein Authorization-Header: direkter Mesh-Weg ohne Auth (siehe compose).
+162 -6
View File
@@ -1,13 +1,169 @@
# Apache: Access-Log → Loki (roh) + optional Request-Metriken → Prometheus
# Apache: Access-Logs → Loki (roh) UND Request-Metriken → Prometheus
#
# Zwei Wege aus einem Log — sie ergaenzen sich:
# inputs.tail : jede Zeile roh nach Loki (fuer die Fehlersuche im Einzelfall)
# inputs.logparser: dieselben Zeilen als Metriken (Request-Rate, Status-Klassen,
# Antwortzeit) — das ist, was Panels und Alerts brauchen
#
# Die Logs landen NICHT in Prometheus: der Remote-Write-Output hat
# `namedrop = ["docker_log", "journal"]` — hier kommt `apache_log` dazu.
#
# FALLE: `from_beginning` existiert bei inputs.tail NICHT (weder im Docker-Image
# noch im InfluxData-Paket 1.40). Ein solcher Block laesst den Dienst beim Start
# hart scheitern: "plugin inputs.tail: configuration specified the fields
# [from_beginning]" → systemctl start schlaegt fehl, ALLE Metriken aus.
# Stattdessen: initial_read_offset = "saved-or-end" (Default), damit beim ersten
# Lauf nicht die komplette Historie ausgeschuettet wird.
# --------------------------------------------------------------------------
# 1) Roh-Logs → Loki
# --------------------------------------------------------------------------
# ${APACHE_FILES} rendert zu einer echten TOML-Liste — der Installer baut sie
# aus allen gefundenen Logpfaden (Panel-Hoster haben pro VHost eigene Dateien).
# Ein einzelner String mit Leerzeichen ("/a.log /b.log") waere ein ungueltiger
# Pfad und der Input liefe still leer.
[[inputs.tail]]
files = ["${APACHE_LOG}"]
from_beginning = false
name_override = "apache_log"
files = ${APACHE_FILES}
name_override = "journal"
# Beim Erstanlauf nur Neues lesen; danach Position merken.
initial_read_offset = "saved-or-end"
data_format = "value"
data_type = "string"
# Vereinheitlicht den Feldnamen auf `message` — das Logs-Panel in Grafana
# erwartet es; `value` wuerde als value="…" erscheinen.
path_tag = "logfile"
[inputs.tail.tags]
source = "apache"
# Optional: mod_status (a2enmod status; ExtendedStatus On; /server-status?auto lokal freigeben)
# Vereinheitlicht den Feldnamen auf `message` — fuer JEDEN Log-Input.
# `apache_error` muss mit drin sein: sonst heisst das Feld dort `value` und
# jede Panel-/Alert-Query mit `| logfmt | line_format "{{.message}}"` findet
# die Error-Logs nicht. Das faellt nicht auf, solange niemand danach sucht.
[[processors.rename]]
namepass = ["journal", "apache_error"]
order = 1
[[processors.rename.replace]]
field = "value"
dest = "message"
# --------------------------------------------------------------------------
# 2) Error-Logs → Loki (NUR roh, KEINE Metriken)
# --------------------------------------------------------------------------
# Error-Logs haben ein anderes Format als Access-Logs:
# [Mon Sep 14 00:10:01.123456 2026] [core:error] [pid 9] AH00124: ...
# Der Access-Grok weiter unten kann das nicht lesen. Liefen sie durch ihn,
# gaebe es keine Metriken — aber der Aggregator wuerde sie in leere Gruppen
# einordnen. Deshalb ein EIGENER Input, der sie unveraendert nach Loki schickt.
#
# Eigener Messungsname `apache_error` statt `journal`: so sind sie in Loki
# eindeutig filterbar, und das Logs-Panel kann sie getrennt zeigen.
[[inputs.tail]]
files = ${APACHE_ERROR_FILES}
name_override = "apache_error"
initial_read_offset = "saved-or-end"
data_format = "value"
data_type = "string"
path_tag = "logfile"
# Der Remote-Write-Output laesst Logs per namedrop draussen; Loki nimmt sie.
[inputs.tail.tags]
source = "apache"
logtype = "error"
# --------------------------------------------------------------------------
# 3) Request-Metriken (Common/Combined Log Format) → Prometheus
# --------------------------------------------------------------------------
# Common Log Format:
# 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326
# VHost-Apache (Debian-Default) stellt den Hostnamen voran:
# example.com:80 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.0" 200 2326
[[inputs.tail]]
files = ${APACHE_FILES}
name_override = "apache_access"
initial_read_offset = "saved-or-end"
data_format = "grok"
# Korrekte Namen der Telegraf-Grok-Sammlung: COMMON_LOG_FORMAT und
# COMBINED_LOG_FORMAT — NICHT HTTPD_* (das sind andere Muster, Telegraf kennt
# sie nicht: "no pattern found for %{HTTPD_COMBINED_LOG_FORMAT}").
# Reihenfolge: spezifischste zuerst — VHost-Varianten vor der einfachen Form.
# EIGENES MUSTER statt %{COMBINED_LOG_FORMAT} — wegen eines realen Ausfalls.
#
# WARUM EIN EIGENES MUSTER: das eingebaute Muster vergibt dem Log-Zeitfeld den
# Namen `ts`. Telegraf nimmt einen erkannten Zeitstempel dann als SAMPLE-ZEIT,
# und die Log-Zeile hat nur SEKUNDEN-Aufloesung. Mit `log_time` findet Telegraf
# kein Zeitfeld mehr und stempelt mit eigener Uhr. Die Log-Zeit bleibt als
# Feld erhalten — praktisch, weil Remote Write nur MILLISEKUNDEN kann.
#
# DER EIGENTLICHE FEHLER liegt tiefer, und ein Zeitstempel-Trick loest ihn
# NICHT: Telegraf schreibt pro Sammelzyklus einen Batch mit EINEM Zeitstempel.
# Treffen zwei Requests derselben Serie (path+resp_code+verb) in dieselbe
# Millisekunde, entsteht "duplicate sample ... overrides not allowed".
# Prometheus weist den Batch ab, Telegraf wiederholt ihn ENDLOS — der Batch
# sitzt im FIFO-Kopf fest und der Node liefert danach GAR NICHTS mehr, auch
# kein system_uptime. Erkennungsmerkmal: derselbe eingefrorene Timestamp
# wiederholt sich alle ~15 s im Prometheus-Log.
#
# DIE LOESUNG ist deshalb der Aggregator weiter unten: er fasst alle
# Metriken einer Serie pro Intervall zu EINEM Punkt zusammen. Pro Serie und
# Intervall gibt es dann genau einen Punkt — eine Kollision ist strukturell
# unmoeglich, unabhaengig von der Last. Gemessen an 2000 echten x22-Zeilen:
# ohne Aggregator ~2000 Einzelpunkte, mit Aggregator 4 Punkte, 0 Konflikte.
grok_custom_patterns = '''
APACHE_LINE (?:%{HOSTNAME:vh}(?::%{NUMBER:vport})? )?%{IPORHOST:client_ip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:log_time}\] "(?:%{WORD:verb} %{NOTSPACE:request}(?: HTTP/%{NUMBER:http_version})?|-)" %{NUMBER:resp_code:int} (?:%{NUMBER:resp_bytes:int}|-)(?: "%{DATA:referrer}" "%{DATA:agent}")?
'''
grok_patterns = ["%{APACHE_LINE}"]
grok_timezone = "UTC"
# Kein Tag-Wildwuchs: nur das Noetige als Tag, der Rest als Feld.
[inputs.tail.tags]
source = "apache"
# resp_code und verb waren im eingebauten Muster TAGS. Das eigene Muster
# liefert sie als Felder — ohne diesen Schritt haetten die Grafana-Panels,
# die nach resp_code filtern, plotzlich keine Daten mehr.
[[processors.converter]]
namepass = ["apache_access"]
order = 1
[processors.converter.fields]
tag = ["resp_code", "verb"]
# --------------------------------------------------------------------------
# Aggregation: der eigentliche Schutz gegen Zeitstempel-Kollisionen
# --------------------------------------------------------------------------
# WARUM DAS NOETIG IST (realer Ausfall, nicht Theorie):
# Telegraf schreibt pro Sammelzyklus einen Batch, dessen Metriken ALLE denselben
# Zeitstempel tragen. Prometheus' Remote Write kann nur Millisekunden. Treffen
# zwei Requests derselben Serie (path + resp_code + verb) in dieselbe
# Millisekunde, antwortet Prometheus:
# "duplicate sample for timestamp ...; overrides not allowed"
# Er verwirft damit den GANZEN Batch. Telegrafs Puffer ist FIFO: der abgelehnte
# Batch bleibt am Kopf stehen und wird endlos wiederholt — die Node liefert
# danach gar nichts mehr, auch keine Systemmetriken. Merkmal im Log: derselbe
# eingefrorene Timestamp alle ~15 s.
#
# Der Aggregator loest das an der Wurzel: er fasst alle Metriken einer Serie pro
# Intervall zu EINEM Punkt zusammen. Damit gibt es pro Serie und Intervall genau
# einen Wert — eine Kollision ist strukturell unmoeglich, egal wie hoch die Last
# ist. Zusaetzlich sinkt die Serienzahl deutlich, was Prometheus entlastet.
#
# Was dabei entsteht (gemessen an 2000 echten x22-Zeilen, 4 Gruppen):
# apache_access_resp_bytes_count — Requests je Gruppe (das brauchen die Panels)
# apache_access_resp_bytes_sum — uebertragene Bytes je Gruppe
# apache_access_resp_bytes_mean — mittlere Antwortgroesse
# Die Felder `resp_code` und `verb` sind Tags und bleiben damit Gruppenschluessel.
#
# `drop_original = true` ist zwingend: ohne das schickt Telegraf die Einzelwerte
# ZUSAETZLICH und die Kollision waere wieder da.
[[aggregators.basicstats]]
namepass = ["apache_access"]
period = "60s"
drop_original = true
stats = ["count", "sum", "mean"]
# --------------------------------------------------------------------------
# 3) mod_status (optional, auskommentiert)
# --------------------------------------------------------------------------
# Liefert Auslastung (BusyWorkers, ReqPerSec) — nuetzlich, braucht aber
# `a2enmod status`, `ExtendedStatus On` und eine lokale Freigabe von
# /server-status in der Apache-Config.
#
# [[inputs.apache]]
# urls = ["http://localhost/server-status?auto"]
# urls = ["http://127.0.0.1/server-status?auto"]
+41 -5
View File
@@ -8,7 +8,18 @@
interval = "15s"
flush_interval = "15s"
metric_batch_size = 1000
metric_buffer_limit = 20000
# metric_buffer_limit: RAM-Puffer fuer noch nicht zugestellte Metriken.
# GEMESSEN (x22, rate ueber 5 min): der Host erzeugt 13,3 Log-Zeilen/s.
# Damit ergibt sich:
# 20.000 -> ~25 min Reserve (alter Wert — zu knapp fuer einen Ausfall)
# 200.000 -> ~4 h Reserve (~100-150 MB RAM, nur im Ausfall belegt)
# Telegraf puffert nur, solange der Push scheitert; im Normalbetrieb bleibt
# der Puffer leer. Die Reserve ist billiger als eine Luecke im Log.
#
# NICHT STILL: ein Ueberlauf wird geloggt
# [outputs.loki] Metric buffer overflow; N metrics have been dropped
# pruefen mit: journalctl -u telegraf | grep -i overflow
metric_buffer_limit = 200000
hostname = "${NODE_NAME}"
omit_hostname = false
@@ -16,8 +27,29 @@
[[outputs.http]]
url = "${PROM_URL}"
data_format = "prometheusremotewrite"
timeout = "10s"
namedrop = ["docker_log", "apache_log", "journal"]
# 60s, NICHT 10s: laeuft die Node hinter dem NetBird-Reverse-Proxy (x05),
# dauert ein POST 11-19 s. Ein 10-s-Timeout bricht dann JEDEN Batch ab —
# gemessen 702 abgelehnte Requests in 20 min, die Metriken fehlen still.
timeout = "60s"
# GEGEN DIE ENDLOSSCHLEIFE — die wichtigste Zeile hier.
# Ein 400 ist PERMANENT: dieselbe Nutzlast liefert dieselbe Antwort. Ohne
# diese Liste bleibt der abgelehnte Batch am FIFO-Kopf stehen, wird fuer
# immer wiederholt und blockiert jede dahinter gesammelte Serie. Die
# gepufferten Samples altern dabei weiter, bis sie aelter sind als die
# Akzeptanzgrenze des Empfaengers (Prometheus: head_min_time ~2x
# Blockdauer, gemessen 90-120 min) — danach sind sie UNZUSTELLBAR und der
# Knoten ist dauerhaft stumm (gemessen 09.10.2026: 9 von 15 Nodes, Versatz
# 185-1071 min, 1680 Ablehnungen/h, danach 'Metric buffer overflow' =
# endgueltiger Verlust). Mit dieser Zeile wird der Batch VERWORFEN statt
# wiederholt ("Received non-retryable status 400. Metrics are lost.") und
# der Knoten erholt sich beim naechsten Flush von selbst.
# 5xx/408/429 bleiben absichtlich retrybar: dort ist die Nutzlast in
# Ordnung, nur der Server ist gerade nicht bereit.
non_retryable_statuscodes = [400, 409, 413, 422]
# Roh-Logs gehoeren NICHT in Prometheus — sie sind Text, keine Zeitreihen.
# `apache_error` hier ergaenzen, sonst versucht der Remote-Write-Output
# Textzeilen als Metriken zu senden.
namedrop = ["docker_log", "apache_log", "journal", "apache_error"]
[outputs.http.headers]
Content-Type = "application/x-protobuf"
Content-Encoding = "snappy"
@@ -28,8 +60,12 @@
[[outputs.loki]]
domain = "${LOKI_URL}"
endpoint = "/loki/api/v1/push"
timeout = "10s"
namepass = ["docker_log", "apache_log", "journal"]
# 60s aus demselben Grund wie oben: der Proxy-Weg braucht 11-19 s.
timeout = "60s"
# `apache_error` MUSS hier stehen: ohne den Namen sammelt der Input die
# Error-Logs ein und dieser Output verwirft sie wieder — kein Fehler, keine
# Meldung, die Panels bleiben leer. Derselbe stille Fehler wie bei `journal`.
namepass = ["docker_log", "apache_log", "journal", "apache_error"]
sanitize_label_names = true
metric_name_label = ""
http_headers = { "Authorization" = "Bearer ${TELEGRAF_TOKEN}" }
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Docker-Events nach Loki (Neustart/OOM/Stop sichtbar machen)
Documentation=file:///usr/local/bin/docker-events-loki.py
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
Restart=always
RestartSec=10
# Kein Timeout: der Prozess ist ein Stream, kein Job.
TimeoutStopSec=15
ExecStart=/usr/local/bin/docker-events-watcher.sh
# Schutz: der Forwarder darf nie die Ursache des naechsten OOM werden.
# Auf Nodes mit wenig RAM (x05: 967 MB) ist das keine Formalie — dort hat ein
# OOM-Kill bereits netbird-proxy erwischt.
MemoryMax=128M
MemorySwapMax=0
Nice=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=docker-events-loki
[Install]
WantedBy=multi-user.target
+15
View File
@@ -19,3 +19,18 @@
[[processors.override]]
namepass = ["docker_log"]
fieldexclude = ["container_id"]
# Ohne diesen zweiten Block faellt die GANZE docker_container_*-Familie aus
# jedem Remote-Write-Batch. `inputs.docker` liefert die Felder `container_id`
# und `image_id` als 64-stellige Hex-Strings; Telegraf flacht Messung+Feld zu
# einem Metriknamen ab, daraus wird `docker_container_blkio_container_id` —
# ein STRING-Wert, den Prometheus ablehnt:
# `bad sample value "<64 hex>"`
# Der 400 wirft den KOMPLETTEN Flush weg, also auch alle Serien, an denen
# nichts falsch ist. Der Block oben trifft nur `docker_log` (Loki-Output) und
# hilft hier NICHT — das ist die Falle, an der man den ganzen Vormittag sucht.
# Gemessen 09.10.2026 auf imac: 278 "series were dropped"-Zeilen in 60 min,
# bis zu 2804 Serien im selben haengenden Batch.
[[processors.override]]
namepass = ["docker", "docker_container*"]
fieldexclude = ["container_id", "container_image_id", "container_id_short", "device_id", "image_id", "health_status"]
+82
View File
@@ -0,0 +1,82 @@
# llama-swap-Telemetrie des ROG — Nutzer-Instanz von Telegraf.
#
# Warum eine zweite Instanz und nicht /etc/telegraf/telegraf.d:
# Auf dem ROG gibt es kein sudo. llama-swap und kalina-backends laufen als
# Nutzer-Units; ihre Logs stehen im Nutzer-Journal und werden von der
# System-Instanz (journal-tail.sh, priority>=warning) verworfen. Eine
# Nutzer-Instanz darf --user-unit lesen und braucht keinen root.
#
# Zugangsdaten NICHT hier: das Systemd-Unit setzt
# EnvironmentFile=/etc/default/telegraf, Telegraf expandiert ${PROM_URL},
# ${LOKI_URL}, ${TELEGRAF_TOKEN} beim Laden. Eine Quelle fuer alle Knoten.
#
# Aufruf: telegraf --config /home/mm/.config/telegraf-llamaswap/telegraf.conf
[global_tags]
node = "ROG"
[agent]
interval = "15s"
flush_interval = "15s"
metric_batch_size = 1000
metric_buffer_limit = 20000
hostname = "ROG"
omit_hostname = false
round_interval = true
skip_processors_after_aggregators = true
# ---- Metriken → Prometheus Remote Write (imac) ----
[[outputs.http]]
url = "${PROM_URL}"
data_format = "prometheusremotewrite"
timeout = "10s"
namedrop = ["journal"]
[outputs.http.headers]
Content-Type = "application/x-protobuf"
Content-Encoding = "snappy"
X-Prometheus-Remote-Write-Version = "0.1.0"
Authorization = "Bearer ${TELEGRAF_TOKEN}"
# ---- Logs → Loki (imac) ----
[[outputs.loki]]
domain = "${LOKI_URL}"
endpoint = "/loki/api/v1/push"
timeout = "10s"
namepass = ["journal"]
sanitize_label_names = true
metric_name_label = ""
http_headers = { "Authorization" = "Bearer ${TELEGRAF_TOKEN}" }
# ---- 1. llama-swap selbst: System- und GPU-Werte ----
# /metrics liefert llama-swap nativ (CPU, RAM, Netz, GPU-Auslastung, VRAM,
# Temperatur, Leistungsaufnahme). Namen sind schon llamaswap_*.
[[inputs.prometheus]]
urls = ["http://192.168.188.184:9300/metrics"]
metric_version = 2
interval = "30s"
timeout = "10s"
[inputs.prometheus.tags]
app = "llama-swap"
# ---- 2. Token-/Request-Zaehler je Modell ----
# Ein exec statt fester Ports: llama-swap vergibt die Upstream-Ports dynamisch
# (${PORT}, startPort 9340) und nur laufende Modelle haben ueberhaupt eine
# /metrics. Das Skript fragt llama-swap selbst und ergaenzt das model-Label.
[[inputs.exec]]
commands = [["/home/mm/bin/llamaswap-telemetrie.py"]]
data_format = "prometheus"
interval = "60s"
timeout = "25s"
[inputs.exec.tags]
app = "llama-swap"
# ---- 3. Logs der Nutzer-Units (llama-swap, kalina-backends) ----
# Gleiche Zeilenform wie journal-tail.sh (measurement journal), damit die
# bestehenden Log-Panels ohne Sonderfall greifen.
[[inputs.exec]]
commands = [["/home/mm/bin/llamaswap-logs.py"]]
data_format = "influx"
interval = "30s"
timeout = "20s"
[inputs.exec.tags]
app = "llama-swap"
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Telegraf — llama-swap-Telemetrie (ROG, Nutzer-Instanz)
Documentation=https://github.com/influxdata/telegraf
# Ohne llama-swap gibt es nichts zu holen; die Unit startet trotzdem, damit
# ein spaeterer llama-swap-Start nicht am Logger scheitert (After statt Requires).
After=network-online.target llama-swap.service
Wants=llama-swap.service
[Service]
Type=notify
NotifyAccess=all
# Dieselben Zugangsdaten wie die System-Instanz: eine Quelle pro Knoten,
# keine Kopie des Tokens in dieser Unit oder im Conf.
EnvironmentFile=/etc/default/telegraf
ExecStart=/usr/bin/telegraf --config /home/mm/.config/telegraf-llamaswap/telegraf.conf
Restart=on-failure
RestartForceExitStatus=SIGPIPE
KillMode=mixed
LimitMEMLOCK=8M:8M
[Install]
WantedBy=default.target