Przejdź do treści
Lekcja 114 z 117

Kurs › Moduł 13 › Lekcja 114

Projekt 5: analizator logów z raportem dziennym

⏱ ok. 17 min czytania· moduł 13: Projekty końcowe

Po tej lekcji: kursant ma skrypt, który co rano przegląda logi Apache i SSH z poprzedniej doby, robi z nich krótki raport z rankingami i alarmami, zapisuje go do pliku, wysyła mailem i odpala się sam z crona albo timera systemd.

Wstęp

Poniedziałek rano. Szef wchodzi i pyta: „Czy ktoś nas w weekend atakował? I czemu klient mówi, że w niedzielę strona rzucała błędami?”.

Możesz odpowiedzieć „sprawdzę” i spędzić godzinę z less w logach. Albo otwierasz maila, który przyszedł o 6:15, i czytasz z kartki: 79 nieudanych logowań, najczęściej na roota; 59 błędów 500; ktoś szukał na naszej stronie pliku .env i panelu WordPressa, którego nigdy nie mieliśmy.

Dziś budujemy tego maila. Piąty projekt do portfolio: analizator logów, który sam wstaje rano, czyta, liczy i pisze raport. Ty w tym czasie śpisz. Albo udajesz, że nie śpisz na porannym spotkaniu.

Co właściwie siedzi w logach

Serwer WWW to recepcja hotelu, pamiętasz. Każdy hotel ma księgę gości: kto przyszedł, kiedy, o co prosił i czy dostał. Log dostępu Apache to właśnie taka księga. Jedna linijka to jedna wizyta:

10.0.0.15 - - [02/Nov/2026:12:34:55 +0100] "GET /blog/linux HTTP/1.1" 404 15048 "-" "Mozilla/5.0"

Po kolei, kolumny oddzielone spacjami, tak jak widzi je awk:

  • $1 — adres IP gościa,
  • $4 — data i godzina, z nawiasem kwadratowym na początku,
  • $7 — adres, o który prosił,
  • $9 — kod odpowiedzi: 200 dostał, 404 nie ma takiego pokoju, 500 recepcjonista zemdlał,
  • $10 — ile bajtów mu wysłaliśmy.

To jest tak zwany format „combined”, domyślny dla logu dostępu w obu rodzinach dystrybucji. Różni się tylko miejsce:

[Ubuntu] /var/log/apache2/access.log [RHEL] /var/log/httpd/access_log

Druga księga to dziennik bramki SSH, kto próbował wejść na serwer:

[Ubuntu] /var/log/auth.log [RHEL] /var/log/secure

I tu jest pułapka, na którą sam wpadłem. W Ubuntu 24.04 rsyslog zapisuje datę w nowym, dokładnym formacie:

2026-11-02T08:45:38.000000+01:00 serwer sshd[32636]: Failed password for root from 198.51.100.23 port 32955 ssh2

A w wielu innych systemach, w tym zwykle na RHEL, data jest w starym stylu, bez roku:

Nov  2 08:45:38 serwer sshd[32636]: Failed password for root from 198.51.100.23 port 32955 ssh2

Zwróć uwagę na dwie spacje po „Nov”. Dzień jest wyrównany do dwóch znaków spacją. Nasz skrypt musi rozumieć oba formaty, bo nie wiemy, na jakim serwerze wyląduje.

Logi do ćwiczeń

Na świeżej maszynie wirtualnej logi są prawie puste, a ćwiczyć na produkcyjnym serwerze firmy to zły pomysł. Więc robimy sobie sztuczne. Ten skrypt produkuje 600 wizyt WWW i 120 wpisów SSH na wczoraj i drugie tyle na dziś:

#!/usr/bin/env bash
# generuj_logi.sh — robi przykładowe logi do ćwiczeń (Apache + SSH) za wczoraj i dziś
set -euo pipefail
cel="${1:-./logi}"
mkdir -p "$cel"
RANDOM=42                                   # ziarno: za każdym razem te same "losowe" dane

ip_list=(203.0.113.7 198.51.100.23 192.0.2.44 203.0.113.99 10.0.0.15 192.0.2.200)
urls=(/ / /index.php /kontakt /blog/linux /img/logo.png /wp-login.php /.env /api/status)
kody=(200 200 200 200 200 304 301 404 404 500)
dni=("$(date -d yesterday +%F)" "$(date +%F)")

: > "$cel/access.log"; : > "$cel/auth.log"
for dzien in "${dni[@]}"; do
    d_apache=$(LC_ALL=C date -d "$dzien" +%d/%b/%Y)
    for _ in $(seq 1 600); do
        printf -v czas '%02d:%02d:%02d' $((RANDOM % 24)) $((RANDOM % 60)) $((RANDOM % 60))
        ip=${ip_list[RANDOM % ${#ip_list[@]}]}
        url=${urls[RANDOM % ${#urls[@]}]}
        kod=${kody[RANDOM % ${#kody[@]}]}
        printf '%s - - [%s:%s +0100] "GET %s HTTP/1.1" %s %d "-" "Mozilla/5.0"\n' \
            "$ip" "$d_apache" "$czas" "$url" "$kod" $((RANDOM * 3)) >> "$cel/access.log"
    done
    for _ in $(seq 1 120); do
        printf -v czas '%02d:%02d:%02d' $((RANDOM % 24)) $((RANDOM % 60)) $((RANDOM % 60))
        ip=${ip_list[RANDOM % 4]}
        case $((RANDOM % 6)) in
            0|1|2) msg="Failed password for root from $ip port $((RANDOM + 1024)) ssh2" ;;
            3)     msg="Failed password for invalid user admin from $ip port $((RANDOM + 1024)) ssh2" ;;
            4)     msg="Invalid user test from $ip port $((RANDOM + 1024))" ;;
            5)     msg="Accepted publickey for eryk from 10.0.0.15 port $((RANDOM + 1024)) ssh2" ;;
        esac
        printf '%sT%s.000000+01:00 serwer sshd[%d]: %s\n' "$dzien" "$czas" $((RANDOM + 1000)) "$msg" >> "$cel/auth.log"
    done
done
# Logi nie są posortowane po czasie — dla analizatora to bez znaczenia
echo "Gotowe: $(wc -l < "$cel/access.log") linijek WWW, $(wc -l < "$cel/auth.log") linijek SSH w $cel"
Gotowe: 1200 linijek WWW, 240 linijek SSH w ./logi

RANDOM=42 to ziarno losowania. Wpisanie liczby do RANDOM sprawia, że „losowe” liczby za każdym razem wychodzą te same. Brzmi jak oszustwo i trochę nim jest, ale dzięki temu Twój raport będzie wyglądał tak samo jak mój na ekranie. Adresy IP są z puli zarezerwowanej do dokumentacji, więc nikogo niewinnego nie oskarżamy o włamanie.

awk, czyli szatnia z nazwiskami

Całe serce analizatora to awk i jego tablice. Pamiętasz słownik, czyli szatnię z nazwiskami? Podchodzi gość, szatniarz patrzy na numerek i dorzuca kreskę przy jego wieszaku. Na koniec wieczoru widzi, kto przyszedł ile razy.

ip[$1]++ robi dokładnie to: dla każdej linijki dorzuca kreskę przy wieszaku podpisanym adresem IP. Nie trzeba wcześniej zakładać wieszaków, pojawiają się same przy pierwszym gościu.

LC_ALL=C awk -v d="[02/Nov/2026:" 'index($4, d) == 1 { ip[$1]++ }
  END { for (i in ip) print ip[i], i }' logi/access.log | sort -rn | head -3
108 198.51.100.23
102 192.0.2.200
100 10.0.0.15

index($4, d) == 1 znaczy: czwarta kolumna zaczyna się od [02/Nov/2026:. Tak wybieramy tylko jeden dzień. Dwukropek na końcu jest celowy, żeby [02/Nov/2026 nie złapało przypadkiem czegoś dłuższego.

To jest ta sama trójka sort | uniq -c | sort -rn z lekcji o rurach, tylko w jednym przejściu i od razu dla kilku rankingów naraz. Plik czytamy raz, a liczymy IP, kody, godziny i błędy 404 jednocześnie. Przy logu na dwa gigabajty to różnica między „chwila” a „idę na obiad”.

Cały skrypt

plik raport_logow.sh w edytorze, sekcje podświetlane po kolei

#!/usr/bin/env bash
# raport_logow.sh — dzienny raport z logów serwera WWW (Apache) i SSH
# Użycie: raport_logow.sh [-d RRRR-MM-DD] [-w LOG_WWW] [-s LOG_SSH] [-o FOLDER] [-m ADRES] [-h]
set -euo pipefail
export LC_ALL=C                      # angielskie nazwy miesięcy, jak w logach

# --- ustawienia domyślne (różne dla rodzin dystrybucji) ---
if [[ -f /etc/debian_version ]]; then
    LOG_WWW=/var/log/apache2/access.log      # [Ubuntu]
    LOG_SSH=/var/log/auth.log
else
    LOG_WWW=/var/log/httpd/access_log        # [RHEL]
    LOG_SSH=/var/log/secure
fi
DZIEN=$(date -d yesterday +%F)
FOLDER=/var/log/raporty
MAIL_DO=""
PROG_5XX=20          # ile błędów 5xx na dobę to już alarm
PROG_SSH=50          # ile nieudanych logowań SSH to już alarm

pomoc() {
    sed -n '2,3s/^# //p' "$0"
}

while getopts "d:w:s:o:m:h" opt; do
    case $opt in
        d) DZIEN=$OPTARG ;;
        w) LOG_WWW=$OPTARG ;;
        s) LOG_SSH=$OPTARG ;;
        o) FOLDER=$OPTARG ;;
        m) MAIL_DO=$OPTARG ;;
        h) pomoc; exit 0 ;;
        *) pomoc >&2; exit 1 ;;
    esac
done

date -d "$DZIEN" +%F >/dev/null 2>&1 || { echo "Zła data: $DZIEN" >&2; exit 1; }

# Czyta log razem z obróconymi kopiami (access.log.1, access.log.2.gz, access_log-20261102...)
# zcat -f czyta też pliki nieskompresowane
czytaj() {
    local plik=$1
    compgen -G "${plik}*" >/dev/null || { echo "Brak pliku: $plik" >&2; return 0; }
    zcat -f -- "${plik}"*
}

# Daty w trzech formatach, w jakich spotkamy je w logach
D_APACHE=$(date -d "$DZIEN" +%d/%b/%Y)        # 02/Nov/2026
D_ISO=$(date -d "$DZIEN" +%F)                 # 2026-11-02 (rsyslog w Ubuntu 24.04)
D_STARY=$(date -d "$DZIEN" '+%b %e')          # "Nov  2"   (klasyczny format, np. RHEL)

mkdir -p "$FOLDER"
RAPORT="$FOLDER/raport-$D_ISO.txt"
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT

{
echo "=== Raport z logów: $D_ISO, serwer $(hostname) ==="
echo
echo "--- WWW ($LOG_WWW) ---"
czytaj "$LOG_WWW" | awk -v d="[$D_APACHE:" '
    index($4, d) == 1 {
        n++; ip[$1]++; bajty += $10
        kl = substr($9, 1, 1) "xx"; kody[kl]++
        split($4, t, ":"); godz[t[2]]++
        if ($9 == 404) nf[$7]++
        if ($9 ~ /^5/) b5++
    }
    END {
        if (n == 0) { print "Brak żądań w tym dniu."; print "#5XX 0"; exit }
        u = 0; for (i in ip) u++
        printf "Żądania: %d, unikalne IP: %d, wysłano: %.1f MB\n", n, u, bajty/1048576
        printf "Kody: 2xx=%d 3xx=%d 4xx=%d 5xx=%d\n", kody["2xx"], kody["3xx"], kody["4xx"], kody["5xx"]
        max = 0; for (g in godz) if (godz[g] > max) { max = godz[g]; gm = g }
        printf "Godzina szczytu: %s:00 (%d żądań)\n", gm, max
        print "Najaktywniejsze IP:"
        for (i in ip) printf "  %6d  %s\n", ip[i], i | "sort -rn | head -5"
        close("sort -rn | head -5")
        print "Najczęstsze 404:"
        for (u2 in nf) printf "  %6d  %s\n", nf[u2], u2 | "sort -rn | head -5"
        close("sort -rn | head -5")
        printf "#5XX %d\n", b5
    }'
echo
echo "--- SSH ($LOG_SSH) ---"
czytaj "$LOG_SSH" | awk -v iso="${D_ISO}T" -v stary="${D_STARY} " '
    index($0, iso) != 1 && index($0, stary) != 1 { next }    # nie ten dzień
    !/sshd/ { next }                                         # nie SSH
    /Failed password/   { f++; for (i = 1; i <= NF; i++) if ($i == "from") fip[$(i+1)]++ }
    /[Ii]nvalid user/   { for (i = 1; i <= NF; i++) if ($i == "user") { iu[$(i+1)]++; break } }
    /Accepted/          { for (i = 1; i <= NF; i++) if ($i == "for") ok[$(i+1) " z " $(i+3)]++ }
    END {
        printf "Nieudane logowania: %d\n", f
        print "Skąd najwięcej prób:"
        for (i in fip) printf "  %6d  %s\n", fip[i], i | "sort -rn | head -5"
        close("sort -rn | head -5")
        print "Nieistniejący użytkownicy, na których próbowano:"
        for (i in iu) printf "  %6d  %s\n", iu[i], i | "sort -rn | head -5"
        close("sort -rn | head -5")
        print "Udane logowania:"
        for (i in ok) printf "  %6d  %s\n", ok[i], i
        printf "#SSH %d\n", f
    }'
} > "$TMP"

# Wyciągamy liczniki do alarmów i usuwamy je z raportu
b5=$(awk '$1 == "#5XX" {print $2}' "$TMP")
ssh_f=$(awk '$1 == "#SSH" {print $2}' "$TMP")
grep -v '^#' "$TMP" > "$RAPORT"

{
    echo
    echo "--- Alarmy ---"
    alarm=0
    if (( ${b5:-0} > PROG_5XX )); then echo "UWAGA: ${b5} błędów 5xx (próg ${PROG_5XX})"; alarm=1; fi
    if (( ${ssh_f:-0} > PROG_SSH )); then echo "UWAGA: ${ssh_f} nieudanych logowań SSH (próg ${PROG_SSH})"; alarm=1; fi
    (( alarm )) || echo "Brak. Można spokojnie pić kawę."
} >> "$RAPORT"

cat "$RAPORT"

if [[ -n "$MAIL_DO" ]] && command -v mail >/dev/null; then
    temat="Raport logów $D_ISO z $(hostname)"
    grep -q '^UWAGA' "$RAPORT" && temat="[ALARM] $temat"
    mail -s "$temat" "$MAIL_DO" < "$RAPORT"
fi

Przejdźmy przez to po kawałku, bo każdy fragment to coś, co już znasz z kursu.

export LC_ALL=C. Logi Apache mają angielskie skróty miesięcy: Nov, nie lis. Jeśli Twój system mówi po polsku, date +%b zwróci lis i skrypt nie znajdzie ani jednej linijki. Wrócimy do tego w typowym błędzie, bo to klasyk.

Ustawienia domyślne. Skrypt sam zgaduje rodzinę dystrybucji po pliku /etc/debian_version i ustawia ścieżki. Opcjami -w i -s możesz je nadpisać, co przyda się do testów na sztucznych logach.

getopts. Przyciski w windzie na każde piętro: -d data, -o folder na raporty, -m adres maila, -h pomoc. Pomoc czytamy z komentarza na górze pliku, żeby nie pisać tego samego dwa razy.

czytaj. Logi się obracają. W nocy logrotate odkłada wczorajszy access.log jako access.log.1, a starsze pakuje do .gz. [RHEL] tam kopie zwykle mają datę w nazwie, np. access_log-20261102. Jeśli raport startuje o 6:15, a obracanie było o północy, połowa wczorajszego dnia leży już w innym pliku. Dlatego czytamy wszystko, co zaczyna się od nazwy logu, a zcat -f łyka bez marudzenia i pliki spakowane, i zwykłe.

Trzy formaty daty. Apache: 02/Nov/2026. Nowy rsyslog: 2026-11-02T. Stary rsyslog: Nov 2 . %e daje dzień wyrównany spacją, dokładnie tak jak w logu.

Dwa bloki awk. Pierwszy liczy WWW, drugi SSH. Z wnętrza awk wysyłamy wiersze rurą do sort -rn | head -5, a close() mówi: koniec tego rankingu, wypisz go teraz, zanim zacznę następny. Bez close rankingi mogłyby wyjść na końcu, w złej kolejności.

Liczniki ze znaczkiem #. Oba bloki na końcu wypisują linijkę typu #5XX 59. To liścik dla samego skryptu: wyciągamy z niego liczby do alarmów, a potem grep -v '^#' wyrzuca liściki z raportu, żeby szef ich nie zobaczył. Plik tymczasowy sprząta trap, nasza sprzątaczka na koniec imprezy.

Alarmy i mail. Jeśli przekroczony jest próg, w temacie maila pojawia się [ALARM]. Dzięki temu w skrzynce od razu widać, który raport trzeba przeczytać, a który można zostawić na po kawie.

Uruchamiamy

chmod +x raport_logow.sh
./raport_logow.sh -w logi/access.log -s logi/auth.log -o ./raporty
=== Raport z logów: 2026-11-02, serwer vm ===

--- WWW (logi/access.log) ---
Żądania: 600, unikalne IP: 6, wysłano: 27.7 MB
Kody: 2xx=289 3xx=119 4xx=133 5xx=59
Godzina szczytu: 07:00 (32 żądań)
Najaktywniejsze IP:
     108  198.51.100.23
     102  192.0.2.200
     100  10.0.0.15
      99  203.0.113.99
      97  192.0.2.44
Najczęstsze 404:
      26  /
      20  /wp-login.php
      18  /.env
      17  /blog/linux
      15  /img/logo.png

--- SSH (logi/auth.log) ---
Nieudane logowania: 79
Skąd najwięcej prób:
      25  203.0.113.7
      23  203.0.113.99
      18  198.51.100.23
      13  192.0.2.44
Nieistniejący użytkownicy, na których próbowano:
      24  test
      23  admin
Udane logowania:
     17  eryk z 10.0.0.15

--- Alarmy ---
UWAGA: 59 błędów 5xx (próg 20)
UWAGA: 79 nieudanych logowań SSH (próg 50)

I to jest dokładnie ten mail z haka. /wp-login.php i /.env w rankingu 404 to klasyka: automaty z całego internetu pukają do każdego serwera i sprawdzają, czy ktoś nie zostawił na wierzchu WordPressa albo pliku z hasłami. Na prawdziwym serwerze zobaczysz to po kilku godzinach od wystawienia go do sieci. Witamy w internecie.

Sprawdziłem też trzy rzeczy, które w życiu się zdarzą. Dzień bez żadnych wpisów, -d 2020-01-01: raport mówi „Brak żądań w tym dniu” i „Brak. Można spokojnie pić kawę”. Log podzielony na access.log i spakowany access.log.1.gz: wynik identyczny jak z jednego pliku. Log SSH w starym formacie Nov 2: te same liczby.

Niech wstaje sam

Budzik, czyli cron. Plik /etc/cron.d/raport-logow, wspólny dla obu rodzin:

# min godz dzień mies dzień_tyg  użytkownik  komenda
15 6 * * *  root  /usr/local/bin/raport_logow.sh -m admin@example.com >/dev/null 2>&1
0  7 * * *  root  find /var/log/raporty -name 'raport-*.txt' -mtime +30 -delete

Druga linijka to sprzątanie: raporty starsze niż 30 dni lecą do kosza. Inaczej za trzy lata ktoś zapyta, czemu /var/log zajmuje pół dysku, i odpowiedź będzie brzmiała „przez Ciebie”.

Pamiętaj, że w /etc/cron.d jest dodatkowa kolumna z użytkownikiem, a na Debianie i Ubuntu nazwa pliku nie może mieć kropki. raport-logow.cron cron po cichu zignoruje.

Druga droga to timer systemd. To Twoje zadanie na dziś, więc nie zdradzam.

Jak to działa pod spodem

awk czyta plik strumieniem, linijka po linijce, tak jak grep. Nie ładuje całego logu do pamięci. W pamięci trzyma tylko tablice, czyli szatnię: jeden wieszak na każdy unikalny adres IP, adres URL i godzinę. Sześć adresów IP to sześć wieszaków, nawet przy milionie linijek. Gorzej, jeśli atakuje Cię sto tysięcy różnych adresów, ale wtedy pamięć zużyta przez awk to najmniejszy z Twoich problemów.

Tablice w awk to tablice mieszające. Klucz, np. 198.51.100.23, zamieniany jest na numer szuflady, więc znalezienie wieszaka trwa tyle samo przy dziesięciu i przy dziesięciu tysiącach gości. Ceną jest to, że for (i in ip) wypisuje je w przypadkowej kolejności. Dlatego zawsze kończymy sort.

Kiedy w awk piszesz print ... | "sort -rn | head -5", awk uruchamia przez powłokę nowy proces i trzyma do niego otwartą rurę. Wszystkie wydruki z tym samym napisem trafiają do tego samego procesu. close() zamyka rurę, sort dostaje koniec danych, sortuje i wypisuje. Taki kucharz, który czeka, aż kelner powie „to wszystkie zamówienia z tego stolika”.

Obracanie logów: logrotate albo zmienia nazwę pliku, a Apache dostaje sygnał, żeby otworzył nowy, albo kopiuje i obcina oryginał. W obu przypadkach wczorajsze linijki mogą leżeć w dwóch plikach, i dlatego czytaj bierze wszystkie.

Ściąga

KomendaFlagaCo robi
awk-v nazwa=wartośćprzekazuje zmienną z Basha do awk
awkindex(s, t) == 1sprawdza, czy napis s zaczyna się od t
awktab[klucz]++licznik w szatni z nazwiskami
awkprint ... | "cmd" + close("cmd")wysyła wynik do innej komendy i zamyka ją
zcat-fczyta i .gz, i zwykłe pliki
date-d yesterday +%Fwczorajsza data, np. 2026-11-02
date+%d/%b/%Y, '+%b %e'data w stylu Apache i starego syslogu
compgen-G 'wzorzec'sprawdza, czy coś pasuje do wzorca plików
mail-s "temat"wysyła maila z treścią ze standardowego wejścia
openssl x509-enddate, -checkend SEKdata ważności certyfikatu / czy wygaśnie w ciągu SEK sekund
find-mtime +30 -deleteusuwa pliki starsze niż 30 dni

⚠ Typowy błąd

Polski system, wszystko wygląda dobrze, skrypt działa, a raport mówi:

Brak żądań w tym dniu.

Popatrz:

LC_ALL=pl_PL.UTF-8 date -d 2026-11-02 +%d/%b/%Y
grep -c '\[02/lis/2026' logi/access.log
LC_ALL=C date -d 2026-11-02 +%d/%b/%Y
grep -c '\[02/Nov/2026' logi/access.log
02/lis/2026
0
02/Nov/2026
600

date jest uprzejme i mówi po polsku: listopad to „lis”. Apache pisze log zawsze po angielsku. Szukasz w księdze gości słowa, którego tam nigdy nie było. Naprawa: export LC_ALL=C na początku skryptu. Ten błąd jest podstępny, bo na serwerze z angielskim systemem wszystko działa, a wybucha dopiero na maszynie kolegi.

Drugi błąd zrobiłem sam przy pisaniu tej lekcji, więc jest jak najbardziej prawdziwy. W pierwszej wersji filtrowałem log SSH przez grep. Na dniu bez żadnych wpisów skrypt kończył się po cichu, bez raportu i bez komunikatu. Dlaczego? grep, który nic nie znalazł, zwraca kod 1. Przy set -o pipefail cała rura dostaje kod 1, a set -e jak kierowca przy pierwszej kontrolce zatrzymuje cały skrypt. Dlatego w gotowej wersji dzień wybiera awk, który przy braku dopasowań zwraca kod 0. Druga możliwość to grep ... || true. Morał: zawsze testuj skrypt na danych, w których nic nie ma. Puste dni zdarzają się częściej, niż myślisz, na przykład kiedy serwer leżał całą noc. A wtedy raport jest najbardziej potrzebny.

✎ Zadanie

  1. Dopisz do raportu sekcję „Najcięższe adresy”: pięć adresów URL, które wysłały w sumie najwięcej danych, w megabajtach.
  2. Zamiast crona ustaw uruchamianie raportu codziennie o 6:15 przez timer systemd. Jeśli serwer o tej porze był wyłączony, raport ma się zrobić zaraz po starcie. Sprawdź systemctl list-timers.

Dla chętnych, na rozgrzewkę przed następną lekcją: postaw trzy maszyny wirtualne z tym samym systemem i nazwij je web1, web2 i db1.

Pokaż rozwiązanie

Część pierwsza: najcięższe adresy URL. W bloku awk od WWW dochodzą dwie rzeczy. W głównej części sumujemy bajty dla każdego adresu, a w END wypisujemy ranking:

        if ($10 ~ /^[0-9]+$/) bt[$7] += $10
        print "Najcięższe adresy (suma wysłanych danych):"
        for (u3 in bt) printf "  %6.1f MB  %s\n", bt[u3]/1048576, u3 | "sort -rn | head -5"
        close("sort -rn | head -5")

Warunek $10 ~ /^[0-9]+$/ jest po to, że Apache, kiedy nic nie wysłał, wpisuje w tej kolumnie myślnik zamiast zera. Myślnik dodany do sumy to dla awk zero, więc nic by się nie zepsuło, ale porządek musi być.

Najcięższe adresy (suma wysłanych danych):
     5.9 MB  /
     3.6 MB  /wp-login.php
     3.4 MB  /blog/linux
     3.3 MB  /index.php
     3.2 MB  /img/logo.png

Część druga: timer systemd zamiast crona. Dwa pliki w /etc/systemd/system/. Usługa mówi, co zrobić:

# /etc/systemd/system/raport-logow.service
[Unit]
Description=Dzienny raport z logów WWW i SSH

[Service]
Type=oneshot
ExecStart=/usr/local/bin/raport_logow.sh -m admin@example.com

A timer mówi kiedy:

# /etc/systemd/system/raport-logow.timer
[Unit]
Description=Codziennie o 6:15 raport z logów

[Timer]
OnCalendar=*-*-* 06:15
Persistent=true

[Install]
WantedBy=timers.target
sudo systemd-analyze calendar "*-*-* 06:15"
sudo systemctl daemon-reload
sudo systemctl enable --now raport-logow.timer
systemctl list-timers raport-logow.timer
  Original form: *-*-* 06:15
Normalized form: *-*-* 06:15:00
    Next elapse: Wed 2026-11-04 06:15:00 CET

Persistent=true to odpowiedź na „a jeśli serwer był wyłączony”. systemd zapisuje na dysku, kiedy timer ostatnio odpalił. Jeśli po starcie widać, że 6:15 przeszło, kiedy maszyna spała, raport robi się od razu. Cron w takiej sytuacji wzrusza ramionami: budzik dzwonił w pustym pokoju, trudno. Włączamy timer, nie service. Kto włączył usługę, ten dostał raport raz, w chwili włączenia, i potem się dziwił.

Co dalej

W następnej lekcji te trzy maszyny dostaną robotę: jednym poleceniem Ansible postawimy na nich cały stos LAMP, dwa serwery WWW i bazę danych, z rolami, inwentarzem i wszystkim w repozytorium Git. Bez klikania i bez logowania się na każdą maszynę osobno.