No description
  • Python 95.3%
  • Shell 4.7%
Find a file
Klaudiusz 53e758d814 fix: User-Agent dla Resend + odporny instalator
Resend stoi za Cloudflare, które odrzucało domyślny User-Agent urllib
(HTTP 403, "error code: 1010") — pierwsza realna wysyłka poszła w kanał
awaryjny zamiast na maila. Własny nagłówek przechodzi.

Instalator sam ustawia XDG_RUNTIME_DIR i DBUS_SESSION_BUS_ADDRESS, bo w
powłoce nieinteraktywnej `systemctl --user` padał na "Failed to connect to
bus". Do tego bity wykonywalne na install.sh i cf_report.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDi7kMUKYJ7ooYC2gRtXXw
2026-08-11 07:31:45 +00:00
cfreport feat: raport Cloudflare z analizą Claude wysyłany mailem 2×/dobę 2026-08-11 07:29:36 +00:00
systemd feat: raport Cloudflare z analizą Claude wysyłany mailem 2×/dobę 2026-08-11 07:29:36 +00:00
.env.example feat: raport Cloudflare z analizą Claude wysyłany mailem 2×/dobę 2026-08-11 07:29:36 +00:00
.gitignore feat: raport Cloudflare z analizą Claude wysyłany mailem 2×/dobę 2026-08-11 07:29:36 +00:00
cf_report.py feat: raport Cloudflare z analizą Claude wysyłany mailem 2×/dobę 2026-08-11 07:29:36 +00:00
install.sh fix: User-Agent dla Resend + odporny instalator 2026-08-11 07:31:45 +00:00
README.md feat: raport Cloudflare z analizą Claude wysyłany mailem 2×/dobę 2026-08-11 07:29:36 +00:00

CF-logs-email

Raport ruchu i bezpieczeństwa Cloudflare — policzony, przeanalizowany przez Claude i wysłany mailem. Dwa razy dziennie.

Zamiast logować się do panelu Cloudflare i przeklikiwać wykresy per domena, dostajesz o 8:00 i 21:00 jednego maila: co się działo przez ostatnie 12 godzin na wszystkich domenach, co z tego jest istotne, i co z tym zrobić.

┌─────────────────────────────────────────────────────────────┐
│  CLOUDFLARE · RAPORT NOCNY                                  │
│  10.08 21:00 – 11.08 09:00                                  │
├─────────────────────────────────────────────────────────────┤
│  ● ALARM                                                    │
│  example.com: 5,3% odpowiedzi 5xx, głównie 712× 502 na      │
│  api.example.com — realna awaria własnej usługi.            │
│                                                             │
│  CO WIDAĆ                                                   │
│  • 1057 odpowiedzi 5xx na 19 765 zapytań…                   │
│  • shop.example.com: 9/9 zapytań zwróciło 530…              │
│                                                             │
│  REKOMENDACJE                                               │
│  → Sprawdź backend api.example.com…                         │
└─────────────────────────────────────────────────────────────┘

Spis treści


Co ląduje w skrzynce

Mail ma sześć sekcji, w kolejności od najważniejszej:

# Sekcja Zawartość
1 Podsumowanie Werdykt (ok / uwaga / alarm) + narracja: co się zmieniło, co z tym zrobić, czego dane nie rozstrzygają. To pisze Claude.
2 Kluczowe liczby Zapytania, unikalni, transfer, cache hit, zablokowane, 5xx — każde ze zmianą względem poprzedniego okna.
3 Alerty Wykryte mechanicznie odchylenia, posortowane wagą. Sekcja znika, gdy nic nie ma.
4 Ruch godzinowo Słupki godzina po godzinie, wszystkie domeny łącznie, z nałożonymi zagrożeniami.
5 Domeny Karta per domena: mini-KPI, kody odpowiedzi, top kraje, ścieżki z błędami 5xx, najczęściej atakowane ścieżki, najaktywniejsze adresy w WAF.
6 Cisza Domeny z zerowym ruchem — jedna linijka, żeby nie zaśmiecać.

Szata graficzna jest odporna na klienty pocztowe: układ na tabelach, style inline, zero JavaScriptu, zero zewnętrznych zasobów, zero SVG (Gmail je wycina). Wykresy są zbudowane z komórek tabeli, więc renderują się tak samo w Gmailu, Apple Mail i Outlooku. Tryb ciemny obsłużony przez prefers-color-scheme, z jasnym wariantem jako punktem wyjścia.

Każdy mail ma też pełnowartościową wersję tekstową (text/plain) — dla czytników, które HTML-a nie pokażą.

Podział ról: kto liczy, a kto komentuje

To najważniejsza zasada w tym projekcie:

Wszystkie liczby pochodzą z warstwy mechanicznej. Model wyłącznie komentuje.

Claude nie ma dostępu do sieci ani do narzędzi — dostaje gotowy, policzony digest i zwraca sam tekst. Dodatkowo każda liczba ≥ 100 wypisana przez model jest porównywana z digestem (insights.verify_numbers); wartość spoza źródła trafia do logu i do widocznej adnotacji w mailu. Jeśli analiza padnie, zawiesi się albo zwróci śmieci — mail i tak wychodzi, z podsumowaniem policzonym mechanicznie i wyraźną informacją, że warstwa AI była niedostępna. Cisza nigdy nie udaje, że wszystko jest w porządku.


Jak to działa

flowchart LR
    T["systemd timer<br/>08:00 · 21:00"] --> A

    subgraph P ["cf_report.py"]
      A["cfapi.py<br/>Cloudflare GraphQL"] --> B["analyze.py<br/>agregacja + alerty"]
      B --> C["insights.py<br/>digest → Claude"]
      C --> D["render.py<br/>HTML + tekst"]
      D --> E["mailer.py<br/>Resend"]
    end

    E --> M["📧 skrzynka"]
    D --> H["archiwum HTML"]
    B --> S["state.json"]
    E -. "gdy poczta padnie" .-> F["kanał awaryjny"]

Przebieg jednego biegu:

  1. Pobranie — lista stref z REST, potem równolegle (5 wątków) per strefa: metryki bieżącego okna, metryki poprzedniego okna (do delt), szereg godzinowy, kody odpowiedzi, top kraje i hosty, ścieżki 5xx, zdarzenia WAF, oraz 7-dniowa baza porównawcza. Retry z wykładniczym backoffem na 429 i 5xx.
  2. Agregacja — sumy, udziały, delty, cache hit ratio; ze zdarzeń WAF powstaje podsumowanie (akcje, top IP z ASN, top ścieżki, reguły).
  3. Alerty — reguły niżej. Nowi atakujący są rozpoznawani przez porównanie z 7-dniowym stanem w state.json.
  4. Analiza — digest (~10 KB JSON) idzie do claude -p w trybie headless. Model zwraca JSON: werdykt, nagłówek, wnioski, rekomendacje, rzeczy do sprawdzenia.
  5. Render — HTML odporny na klienty pocztowe + wersja tekstowa. Kopia HTML ląduje w archiwum.
  6. Wysyłka — Resend, 3 próby z backoffem. Porażka → kanał awaryjny + niezerowy kod wyjścia (widoczny w systemctl --user status).

Dlaczego okno 12-godzinne

Mail o 8:00 pokrywa 21:00 → 8:00 (noc), mail o 21:00 pokrywa 8:00 → 21:00 (dzień). Okna stykają się dokładnie i nie nachodzą — nic nie wypada i nic nie jest liczone dwa razy. Do tego dochodzi kontekst 7-dniowy jako punkt odniesienia dla anomalii.

Dlaczego strefa czasowa jest w OnCalendar

Host chodzi w UTC. Bez Europe/Warsaw wpisanego wprost w specyfikację kalendarza raport dryfowałby o godzinę przy każdej zmianie czasu:

OnCalendar=*-*-* 08,21:00:00 Europe/Warsaw

Persistent=true dorzuca nadrobienie biegu pominiętego przez wyłączony host.


Co gdzie leży

W repozytorium

Ścieżka Odpowiedzialność
cf_report.py Punkt wejścia CLI — parsowanie argumentów, orkiestracja, archiwum, kody wyjścia.
cfreport/config.py Konfiguracja ze zmiennych środowiskowych. Nigdy nie loguje wartości sekretów — describe() pokazuje wyłącznie fakt ich obecności.
cfreport/cfapi.py Klient Cloudflare: REST (strefy) + GraphQL Analytics. Retry, backoff, zapytania.
cfreport/analyze.py Agregacja, delty, podsumowanie WAF, reguły alertów, trwały stan między biegami.
cfreport/insights.py Budowa digestu, wywołanie claude -p, parsowanie odpowiedzi, walidator liczbowy, podsumowanie awaryjne.
cfreport/render.py HTML odporny na klienty pocztowe + wersja tekstowa. Wykresy z komórek tabeli.
cfreport/mailer.py Wysyłka przez Resend + kanał awaryjny (argv, bez powłoki).
systemd/cf-report.service Jednostka uruchomieniowa (tryb user).
systemd/cf-report.timer Harmonogram 08:00 / 21:00 czasu warszawskiego.
install.sh Katalogi, konfiguracja z wzorca, instalacja i włączenie timera.
.env.example Wzorzec konfiguracji — wyłącznie placeholdery.

Bez zewnętrznych zależności — tylko biblioteka standardowa Pythona (3.9+). Żadnego pip install, żadnego venva.

Na maszynie

Ścieżka Zawartość Prawa
~/.config/cf-report/env Sekrety i konfiguracja. Poza repozytorium. 600
~/.config/systemd/user/cf-report.{service,timer} Jednostki systemd. 644
~/.local/state/cf-report/state.json Znane adresy atakujących (7 dni) + metryki ostatnich biegów. 644
~/.cache/cf-report/claude/ Neutralny katalog roboczy dla claude -p. 755
Katalog z REPORT_ARCHIVE_DIR Kopie HTML wysłanych raportów (rotacja: ostatnie N). 755

Dlaczego claude dostaje osobny katalog roboczy: uruchomiony w katalogu projektu wciągnąłby CLAUDE.md, hooki i skille z tego katalogu, i zanieczyściłby analizę cudzym kontekstem. Neutralny katalog + --strict-mcp-config + pusta lista narzędzi dają czysty, powtarzalny wynik.


Instalacja

git clone https://git.zatto.pl/nerdokracja/CF-logs-email.git ~/cf-logs-email
cd ~/cf-logs-email
./install.sh

Instalator sprawdza wymagania, tworzy katalogi, kopiuje wzorzec konfiguracji do ~/.config/cf-report/env z prawami 600 i włącza timer.

Potem uzupełnij klucze i sprawdź, czy wszystko wstało:

$EDITOR ~/.config/cf-report/env

set -a && . ~/.config/cf-report/env && set +a
python3 cf_report.py --check-config          # bez wartości sekretów
python3 cf_report.py --dry-run --out /tmp/raport.html

Wymagany linger. Bez niego usługi użytkownika nie startują przed pierwszym logowaniem:

sudo loginctl enable-linger "$USER"

Konfiguracja

Wszystko przez zmienne środowiskowe w ~/.config/cf-report/env. Pełny wzorzec: .env.example.

Cloudflare

Zmienna Wymagana Opis
CF_API_TOKEN — Token API. Wariant zalecany. Wystarczy zakres Zone:Read + Analytics:Read.
CF_API_EMAIL + CF_API_KEY — Globalny klucz API. Działa, ale ma pełne uprawnienia do konta — używaj tylko gdy token nie wchodzi w grę.

Ustaw jedno albo drugie. Brak obu = błąd konfiguracji przy starcie.

Poczta

Zmienna Domyślnie Opis
RESEND_API_KEY — Klucz API Resend. Wymagany.
MAIL_FROM — Nadawca. Domena musi być zweryfikowana w Resend.
MAIL_TO — Odbiorcy po przecinku.
MAIL_SUBJECT_PREFIX Cloudflare Prefiks tematu.

Dlaczego Resend, a nie lokalny MTA: poczta wysyłana wprost z adresu domowego/serwerowego bez ustawionego relayu ląduje w spamie albo jest odrzucana. Resend ma zweryfikowaną domenę, SPF i DKIM.

Zakres

Zmienna Domyślnie Opis
REPORT_TZ Europe/Warsaw Strefa czasowa w treści maila.
REPORT_WINDOW_HOURS 12 Długość okna.
REPORT_ZONES_ONLY wszystkie Ogranicz do wskazanych stref (po przecinku).
REPORT_ZONES_SKIP — Pomiń wskazane strefy.

Domyślnie skrypt ciągnie listę stref z API, więc nowa domena pojawi się w raporcie sama, bez edycji konfiguracji.

Analiza

Zmienna Domyślnie Opis
INSIGHTS_ENABLED 1 0 wyłącza analizę AI — raport leci z podsumowaniem mechanicznym.
CLAUDE_BIN claude Ścieżka do CLI.
CLAUDE_MODEL claude-sonnet-5 Model.
CLAUDE_TIMEOUT 240 Limit w sekundach. Po przekroczeniu → podsumowanie mechaniczne.
CLAUDE_WORKDIR ~/.cache/cf-report/claude Neutralny katalog roboczy.

Stan, archiwum, awaria

Zmienna Domyślnie Opis
REPORT_STATE ~/.local/state/cf-report/state.json Stan między biegami.
REPORT_ARCHIVE_DIR ~/.local/share/cf-report/archive Kopie HTML.
REPORT_ARCHIVE_KEEP 60 Ile kopii trzymać.
FALLBACK_COMMAND — Uruchamiane, gdy poczta padnie. Token {msg} dostaje treść jako pojedynczy argument, bez powłoki.

Progi alertów

Zmienna Domyślnie Znaczenie
ALERT_5XX_RATIO 0.02 Udział 5xx, od którego leci alarm.
ALERT_5XX_MIN_REQUESTS 50 Minimalna próbka, żeby udział miał sens.
ALERT_TRAFFIC_DELTA 0.60 Zmiana ruchu uznana za nietypową.
ALERT_TRAFFIC_MIN_BASE 500 Minimalna baza, żeby nie alarmować o szumie.
ALERT_NEW_ATTACKER_BLOCKS 50 Ile bloków czyni „nowego atakującego".
ALERT_CACHE_DROP_PP 20.0 Spadek cache hit w punktach procentowych.
ALERT_THREAT_MULTIPLIER 3.0 Krotność średniej 7-dniowej dla zagrożeń.

Użycie

set -a && . ~/.config/cf-report/env && set +a

python3 cf_report.py                              # pełny bieg + wysyłka
python3 cf_report.py --dry-run --out /tmp/r.html  # render bez wysyłki
python3 cf_report.py --no-insights --dry-run      # pomiń AI (szybko, ~4 s)
python3 cf_report.py --window 24                  # doraźny raport dobowy
python3 cf_report.py --zones example.com          # jedna domena
python3 cf_report.py --to ktos@example.com        # inny odbiorca
python3 cf_report.py --dump-digest /tmp/d.json    # podejrzyj wsad dla modelu
python3 cf_report.py --print-text                 # wersja tekstowa na stdout
python3 cf_report.py --check-config               # sprawdź konfigurację
Flaga Działanie
--dry-run Policz i wyrenderuj, ale nie wysyłaj.
--out PLIK Zapisz HTML do pliku.
--print-text Wypisz wersję tekstową na stdout.
--no-insights Pomiń analizę Claude.
--window H Nadpisz długość okna.
--to ADRES Nadpisz odbiorcę (można wielokrotnie).
--zones LISTA Ogranicz do wskazanych stref.
--dump-digest PLIK Zapisz digest wysyłany do modelu.
--no-state Nie zapisuj stanu (testy).
--check-config Wypisz konfigurację bez sekretów i zakończ.
-v Logi debug.

Kody wyjścia: 0 sukces · 1 błąd biegu lub wysyłki · 2 błąd konfiguracji.

Sterowanie timerem

systemctl --user list-timers cf-report.timer     # kiedy następny bieg
systemctl --user start cf-report.service         # bieg ręczny (wyśle maila)
journalctl --user -u cf-report.service -n 50     # logi
systemctl --user disable --now cf-report.timer   # wyłącz

Reguły alertów

Alerty liczone są mechanicznie, niezależnie od analizy AI — model ich nie generuje i nie może ich uciszyć.

Waga Reguła Kiedy
🔴 critical Błędy 5xx Udział 5xx ≥ ALERT_5XX_RATIO przy próbce ≥ ALERT_5XX_MIN_REQUESTS. Alert dołącza najczęstszą ścieżkę i kod.
🔴 critical Ruch spadł do zera Domena miała ≥ ALERT_TRAFFIC_MIN_BASE zapytań w poprzednim oknie, teraz ma 0. Możliwa niedostępność albo zmiana DNS.
🔴 critical Błąd pobrania Cloudflare nie oddał danych dla strefy. Liczby dla niej są nieznane i raport mówi to wprost.
🟠 warning Nietypowa zmiana ruchu Odchylenie ≥ ALERT_TRAFFIC_DELTA względem poprzedniego okna.
🟠 warning Nowy atakujący Adres z ≥ ALERT_NEW_ATTACKER_BLOCKS blokadami, nieobecny w 7-dniowym stanie.
🟠 warning Spadek cache hit Spadek o ≥ ALERT_CACHE_DROP_PP punktów procentowych — więcej ruchu idzie do origin.
🔵 info Podwyższone zagrożenia Liczba zagrożeń ≥ ALERT_THREAT_MULTIPLIER × średnia 7-dniowa dla takiego okna.

Skąd biorą się dane

Cloudflare GraphQL Analytics API, https://api.cloudflare.com/client/v4/graphql:

Dataset Do czego
httpRequests1hGroups Metryki okna i szereg godzinowy: zapytania, unikalni, transfer, cache, zagrożenia.
httpRequests1dGroups 7-dniowa baza porównawcza.
httpRequestsAdaptiveGroups Kody odpowiedzi, top kraje, top hosty, drill-down ścieżek z błędami 5xx.
firewallEventsAdaptive Surowe zdarzenia WAF: akcja, źródło, reguła, IP, ASN, kraj, host, ścieżka.

Plan Free wystarcza. Wszystkie powyższe działają na darmowym planie. Jedyny dataset poza zasięgiem to firewallEventsAdaptiveGroups (gotowe agregaty WAF) — dlatego zdarzenia WAF pobieramy surowe i agregujemy u siebie. Logpush wymagałby Enterprise i nie jest tu potrzebny.

Pobieranie zdarzeń WAF ma limit (domyślnie 2000 na strefę na okno). Po jego osiągnięciu podsumowanie oznacza się jako truncated — liczby są wtedy dolnym oszacowaniem, nie pełnym obrazem.


Bezpieczeństwo

Co ten projekt robi, żeby nic nie wyciekło:

  • Sekrety nigdy nie trafiają do repozytorium. Konfiguracja runtime żyje w ~/.config/cf-report/env z prawami 600. .gitignore dodatkowo blokuje .env*, env, *.key, *.pem, credentials*.json i secrets/ — jako pas bezpieczeństwa na wypadek pomyłkowej kopii do katalogu projektu. Jedyny wersjonowany plik konfiguracyjny to .env.example, zawierający wyłącznie placeholdery.
  • Sekrety nie trafiają do logów. config.describe() raportuje resend_key_present: true, nigdy wartość. Klucze wędrują wyłącznie w nagłówkach HTTP.
  • Wygenerowane raporty są gitignorowane. Zawierają adresy IP, nazwy hostów i statystyki ruchu — *.html, digest*.json, state.json i archive/ nie mają jak przypadkiem wjechać do repo.
  • Model nie dostaje sekretów ani dostępu. claude jest uruchamiany z pustą listą narzędzi i --strict-mcp-config z pustą konfiguracją MCP: bez sieci, bez plików, bez MCP. Widzi wyłącznie policzony digest.
  • Kanał awaryjny nie używa powłoki. FALLBACK_COMMAND jest rozbijany przez shlex.split, a treść raportu wstawiana jako pojedynczy argument — metaznaki w danych nie mają jak się wykonać.
  • Usługa działa bez roota, z NoNewPrivileges, PrivateTmp i ProtectSystem=full.

Uwaga o zakresie uprawnień: token API z zakresem Zone:Read + Analytics:Read jest wyraźnie bezpieczniejszy niż globalny klucz, który daje pełną władzę nad kontem Cloudflare. Jeśli używasz globalnego klucza, zrób to świadomie i przy pierwszej okazji przełącz się na token.


Diagnostyka

Objaw Przyczyna i co zrobić
Konfiguracja: Brak MAIL_TO Plik env nie został wczytany albo wartość ze spacją nie jest w cudzysłowach. MAIL_FROM="Nazwa <adres>" — bez cudzysłowów powłoka się wywraca.
Analiza AI niedostępna: Nie znaleziono polecenia claude Ustaw CLAUDE_BIN na pełną ścieżkę (command -v claude). W usłudze systemd PATH jest inne niż w powłoce.
Analiza AI niedostępna: Model nie odpowiedział w N s Podnieś CLAUDE_TIMEOUT. Mail i tak wyszedł — z podsumowaniem mechanicznym.
Resend HTTP 403 Domena z MAIL_FROM nie jest zweryfikowana w Resend albo klucz jest z innego projektu.
HTTP 400: Invalid access token Token wygasł lub nie ma zakresu Analytics:Read.
Wszystkie strefy z zerowym ruchem Okno przesunięte względem rzeczywistości. Sprawdź --window i strefę czasową hosta (timedatectl).
Timer nie odpalił loginctl show-user $USER -p Linger → jeśli no, włącz linger.
Adnotacja „walidator liczbowy zgłosił…" Model wypisał liczbę spoza digestu. Ufaj tabelom, nie narracji, i zgłoś przypadek.

Podgląd wsadu, który dostaje model:

python3 cf_report.py --dry-run --no-state --dump-digest /tmp/digest.json
python3 -m json.tool /tmp/digest.json | less

Koszt

Składnik Koszt
Cloudflare Analytics API 0 zł — mieści się w planie Free.
Resend 0 zł — 2 maile dziennie mieszczą się w darmowym progu.
Analiza Claude ~40 s i kilkanaście tysięcy tokenów na bieg. Przy subskrypcji to zużycie limitu, nie faktura.

Cały bieg trwa ~45 sekund, z czego ~4 s to pobieranie danych z Cloudflare, a reszta to analiza. Bez analizy (--no-insights) raport powstaje w ~4 sekundy.


Licencja

Wewnętrzne narzędzie Zatto Software.