- Python 95.3%
- Shell 4.7%
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 |
||
|---|---|---|
| cfreport | ||
| systemd | ||
| .env.example | ||
| .gitignore | ||
| cf_report.py | ||
| install.sh | ||
| README.md | ||
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
- Jak to działa
- Co gdzie leży
- Instalacja
- Konfiguracja
- Użycie
- Reguły alertów
- Skąd biorą się dane
- Bezpieczeństwo
- Diagnostyka
- Koszt
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:
- 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.
- Agregacja — sumy, udziały, delty, cache hit ratio; ze zdarzeń WAF powstaje podsumowanie (akcje, top IP z ASN, top ścieżki, reguły).
- Alerty — reguły niżej. Nowi atakujący są rozpoznawani przez porównanie z 7-dniowym stanem w
state.json. - Analiza — digest (~10 KB JSON) idzie do
claude -pw trybie headless. Model zwraca JSON: werdykt, nagłówek, wnioski, rekomendacje, rzeczy do sprawdzenia. - Render — HTML odporny na klienty pocztowe + wersja tekstowa. Kopia HTML ląduje w archiwum.
- 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
claudedostaje osobny katalog roboczy: uruchomiony w katalogu projektu wciągnąłbyCLAUDE.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/envz prawami600..gitignoredodatkowo blokuje.env*,env,*.key,*.pem,credentials*.jsonisecrets/— 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()raportujeresend_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.jsoniarchive/nie mają jak przypadkiem wjechać do repo. - Model nie dostaje sekretów ani dostępu.
claudejest uruchamiany z pustą listą narzędzi i--strict-mcp-configz pustą konfiguracją MCP: bez sieci, bez plików, bez MCP. Widzi wyłącznie policzony digest. - Kanał awaryjny nie używa powłoki.
FALLBACK_COMMANDjest rozbijany przezshlex.split, a treść raportu wstawiana jako pojedynczy argument — metaznaki w danych nie mają jak się wykonać. - Usługa działa bez roota, z
NoNewPrivileges,PrivateTmpiProtectSystem=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.