28 posts 112 tags 7 domains

Bot Telegram: raport ruchu i status serwera

Bot na Telegramie, który na komendę podaje odsłony z logów Apache, load, RAM, dysk i top procesów. Bash, long polling, systemd bez roota.

Problem

W procedurze zabezpieczania serwera bot na Telegramie tylko wysyła: alert przy logowaniu SSH i wynik codziennego self-checku. Czasem chcesz jednak o coś zapytać, np. ile odsłon miały strony w ostatnim tygodniu albo czy serwer nie dobija do limitu RAM-u. Logowanie przez SSH z telefonu tylko po to, żeby uruchomić free -m, to przesada.

Bot dostaje trzy komendy:

KomendaCo zwraca
raport [dni]tabela dzień po dniu: odsłony, unikalne IP, żądania i wejścia robotów Google dla każdej domeny z access logów Apache'a (domyślnie 7 dni, maks. 14)
statusload average, RAM, swap, zajętość dysków
top5 procesów z największym zużyciem CPU i 5 programów z największym zużyciem RAM

Zakładam, że masz już bota i plik /etc/telegram-alert.conf z TG_TOKEN i TG_CHAT z wpisu o hardeningu. Obecne alerty działają dalej bez zmian.

Jak bot odbiera wiadomości

Telegram dostarcza wiadomości do bota na dwa sposoby: webhookiem (Telegram wysyła POST na twój publiczny adres HTTPS) albo przez long polling (getUpdates). Tu wystarczy long polling. Skrypt pyta Telegrama „czy jest coś nowego?” i czeka na odpowiedź do 60 sekund. Gdy przyjdzie wiadomość, dostaje ją od razu. Połączenie zawsze wychodzi z serwera, więc nie potrzebujesz otwartego portu, certyfikatu ani vhosta.

Bot składa się z trzech małych skryptów (po jednym na komendę) i pętli, która je wywołuje. Każdy skrypt działa też samodzielnie w terminalu, co ułatwia testowanie.

Komenda raport: odsłony z access logów

Szablon vhosta z konfiguracji Apache + PHP-FPM zapisuje osobny log dla każdej domeny: /var/log/apache2/<domena>-access.log. Logrotate z Debiana rotuje je codziennie i trzyma 14 kopii (.1, .2.gz…), więc ostatni tydzień zawsze jest na dysku.

bash
$sudo nano /usr/local/sbin/www-report
bash
#!/bin/bash
# Użycie: www-report [dni] — odsłony z access logów Apache'a, dzień po dniu
set -uo pipefail
DAYS=${1:-7}
LOGDIR=${LOGDIR:-/var/log/apache2}
PAT=$(mktemp); trap 'rm -f "$PAT"' EXIT
WD=(nd pn wt śr cz pt so)
KEYS=(); LABELS=()
for ((i=DAYS-1; i>=0; i--)); do
    KEYS+=("$(LC_ALL=C date -d "-$i day" '+%d/%b/%Y')")
    LABELS+=("$(date -d "-$i day" '+%d.%m') ${WD[$(date -d "-$i day" '+%w')]}")
done
printf '[%s:\n' "${KEYS[@]}" > "$PAT"

echo "Ruch za ostatnie $DAYS dni ($(hostname))"
for log in "$LOGDIR"/*-access.log; do
    site=$(basename "$log" -access.log)
    zcat -f "$log" "$log".* 2>/dev/null | grep -F -f "$PAT" |
    awk -v site="$site" -v keys="${KEYS[*]}" -v labels="$(IFS='|'; echo "${LABELS[*]}")" '
        BEGIN { n = split(keys, K, " "); split(labels, L, "|") }
        {
            d = substr($4, 2, 11); hits[d]++; total++
            nq = split($0, q, "\""); ua = tolower(q[nq - 1])
            if (ua ~ /googlebot|google-inspectiontool|googleother|storebot-google|adsbot-google/) { goo[d]++; tgoo++ }
            if ($6 == "\"GET" && $9 ~ /^2/ && $7 !~ /\.(css|js|png|jpe?g|gif|webp|avif|svg|ico|woff2?|map|txt|xml)(\?|$)/ && ua !~ /bot|crawl|spider|curl|wget|python/) {
                views[d]++; tviews++
                if (!((d, $1) in seen)) { seen[d, $1]; uniq[d]++ }
                if (!($1 in all)) { all[$1]; tuniq++ }
            }
        }
        END {
            if (!total) exit
            printf "\n%s\ndzień     odsł.    IP żądania google\n", site
            for (i = 1; i <= n; i++)
                printf "%s %6d %5d %7d %6d\n", L[i], views[K[i]], uniq[K[i]], hits[K[i]], goo[K[i]]
            printf "%-8s %6d %5d %7d %6d\n", "razem", tviews, tuniq, total, tgoo
        }'
done
  • Pętla for buduje listę dni od najstarszego do dzisiaj w dwóch wersjach: w formacie z logu (23/Oct/2026) i jako etykietę wiersza (23.10 pt). LC_ALL=C jest konieczne, bo w polskim locale date zwróciłby paź zamiast Oct. grep -F -f zostawia tylko linie z tych dni, więc awk nie przegląda całego logu.
  • zcat -f czyta tak samo zwykłe i spakowane logi, więc rotacja nie ma znaczenia.
  • awk wycina dzień z pola z datą ([23/Oct/2026:10:00:00) i liczy wszystko osobno dla każdego dnia. Dni bez ruchu też dostają wiersz z zerami, bo tabela jest rysowana z listy dni, a nie z tego, co znalazło się w logu.
  • Żądania to wszystkie linie z logu. Odsłony to udane GET-y stron, bez CSS, JS, obrazków i botów. Bez tego podziału jedno wejście na stronę z 30 zasobami liczyłoby się jako 30 hitów.
  • Google to wszystkie żądania robotów Google: Googlebot (wyszukiwarka i grafika), Google-InspectionTool (test adresu URL w Search Console), GoogleOther, Storebot-Google (Merchant Center) i AdsBot. Liczone są wszystkie żądania, także CSS, JS i obrazki, bo robot pobiera je, żeby wyrenderować stronę. User-Agent jest wycinany jako ostatnie pole w cudzysłowie, więc filtr nie łapie adresów URL, w których akurat występuje słowo bot.
  • IP w wierszu razem to unikalne adresy z całego okresu, a nie suma kolumny. Ktoś, kto wchodził codziennie, liczy się raz.
bash
$sudo chmod 755 /usr/local/sbin/www-report
$sudo www-report 7
Ruch za ostatnie 7 dni (vps1)
nowy-sklep.pl
dzień odsł. IP żądania google
17.10 so 412 131 5210 318
18.10 nd 388 120 4902 297
19.10 pn 790 215 9871 402
20.10 wt 845 233 10433 455
21.10 śr 802 229 9985 389
22.10 cz 771 210 9610 361
23.10 pt 804 226 11229 512
razem 4812 1307 61240 2734

Komenda status: obciążenie, pamięć, dysk

bash
$sudo nano /usr/local/sbin/srv-status
bash
#!/bin/bash
# Użycie: srv-status — obciążenie, pamięć, swap, dyski
set -uo pipefail
read -r l1 l5 l15 _ < /proc/loadavg
echo "$(hostname), $(uptime -p)"
echo "Load:  $l1 / $l5 / $l15 (rdzeni: $(nproc))"
free -m | awk '
    /^Mem:/  { printf "RAM:   %d / %d MB zajęte (%d%%)\n", $2-$7, $2, ($2-$7)*100/$2 }
    /^Swap:/ { if ($2 > 0) printf "Swap:  %d / %d MB (%d%%)\n", $3, $2, $3*100/$2; else print "Swap:  brak" }'
echo "Dyski:"
df -h -x tmpfs -x devtmpfs -x efivarfs -x overlay -x squashfs --output=target,used,size,pcent | tail -n +2 |
    awk '{ printf "  %-16s %5s / %-5s %4s\n", $1, $2, $3, $4 }'
bash
$sudo chmod 755 /usr/local/sbin/srv-status
$sudo srv-status
vps1, up 5 weeks, 6 hours, 10 minutes
Load: 0.12 / 0.06 / 0.01 (rdzeni: 4)
RAM: 2161 / 7820 MB zajęte (27%)
Swap: 0 / 2047 MB (0%)
Dyski:
/ 32G / 75G 43%

Zajęty RAM liczę jako total − available, a nie z kolumny used. Kolumna available uwzględnia cache plików, który kernel odda na żądanie, więc to ona mówi, ile pamięci naprawdę zostało. Load average czytaj w odniesieniu do liczby rdzeni: na 4 rdzeniach 4.0 oznacza pełne obciążenie, a nie przeciążenie.

Komenda top: co zjada CPU i RAM

bash
$sudo nano /usr/local/sbin/srv-top
bash
#!/bin/bash
# Użycie: srv-top — procesy z największym zużyciem CPU (teraz) i RAM (suma na program)
set -uo pipefail
echo "CPU (pomiar 1 s):"
top -b -n 2 -d 1 -w 200 -o %CPU | awk '/^top -/ { n++ } n == 2 && hdr && NF { print } /PID/ { hdr = (n == 2) }' |
    head -5 | awk '{ printf "  %5s%%  %s\n", $9, $12 }'
echo "RAM (suma procesów):"
ps -eo rss=,comm= | awk '{ mem[$2] += $1; cnt[$2]++ } END { for (c in mem) printf "%d %d %s\n", mem[c]/1024, cnt[c], c }' |
    sort -rn | head -5 | awk '{ printf "  %5d MB  %s%s\n", $1, $3, ($2 > 1 ? " ×" $2 : "") }'
bash
$sudo chmod 755 /usr/local/sbin/srv-top
$sudo srv-top
CPU (pomiar 1 s):
12.0% php-fpm8.4
3.0% mariadbd
1.0% apache2
0.0% systemd
0.0% kthreadd
RAM (suma procesów):
1104 MB mariadbd
522 MB php-fpm8.4 ×9
167 MB apache2 ×6
88 MB fail2ban-server
41 MB systemd-journal

Dwie rzeczy, które łatwo zrobić źle:

  • CPU z ps to średnia z całego życia procesu, a nie bieżące zużycie. MariaDB, która od miesiąca prawie nic nie robi, a przez ostatnią minutę mieli zapytanie, pokaże w ps 0.3%. Dlatego CPU mierzy top w dwóch próbkach co sekundę i bierze drugą, bo pierwsza też jest średnią od startu.
  • RAM sumuję po nazwie programu. PHP-FPM i Apache działają jako wiele procesów, a pojedynczy proces php-fpm po 60 MB wygląda niewinnie. Dopiero suma ×9 pokazuje, ile naprawdę zajmuje pula. To ta sama liczba, z której w konfiguracji LAMP liczy się pm.max_children.

Pętla bota

Telegram odpowiada JSON-em, a do jego parsowania bot używa jq, czyli narzędzia, które robi z JSON-em to, co grep i awk ze zwykłym tekstem. Tutaj zamienia każdą wiadomość na jedną linię update_id, chat_id i tekst, rozdzieloną tabulatorami, którą Bash wczyta przez read. W Debianie i Ubuntu nie ma go domyślnie:

bash
$sudo apt install jq
$sudo nano /usr/local/sbin/tg-bot
bash
#!/bin/bash
# Bot Telegrama: odpowiada na komendy, tylko z czatu TG_CHAT
set -uo pipefail
. "$CREDENTIALS_DIRECTORY/tg"
OFFSET=0

# Adres z tokenem idzie do curla przez stdin (-K -), a nie w argumentach, więc nie widać go w ps
tg() { local method=$1; shift; curl -s -K - "$@" <<< "url = \"https://api.telegram.org/bot$TG_TOKEN/$method\""; }

send() {
    local t=$1
    t=${t//'&'/'&amp;'}; t=${t//'<'/'&lt;'}; t=${t//'>'/'&gt;'}
    tg sendMessage -m 10 -d parse_mode=HTML \
        --data-urlencode "chat_id=$TG_CHAT" --data-urlencode "text=<pre>$t</pre>" > /dev/null
}

while true; do
    UPD=$(tg getUpdates -f -m 70 -G -d timeout=60 -d "offset=$OFFSET") || { sleep 10; continue; }
    while IFS=$'\t' read -r id chat text; do
        OFFSET=$((id + 1))
        [ "$chat" = "$TG_CHAT" ] || continue
        read -r cmd arg _ <<< "${text,,}"
        case "${cmd#/}" in
            raport) [[ ${arg:-} =~ ^[0-9]{1,2}$ ]] && arg=$((10#$arg)) && (( arg >= 1 && arg <= 14 )) || arg=7
                    send "$(/usr/local/sbin/www-report "$arg")" ;;
            status) send "$(/usr/local/sbin/srv-status)" ;;
            top)    send "$(/usr/local/sbin/srv-top)" ;;
            *)      send "Komendy: raport [dni], status, top" ;;
        esac
    done < <(jq -r '.result[] | [.update_id, (.message.chat.id // ""), (.message.text // "")] | @tsv' <<< "$UPD")
done
bash
$sudo chmod 755 /usr/local/sbin/tg-bot
  • tg() przekazuje curlowi adres z tokenem przez stdin jako plik konfiguracyjny (-K -). Argumenty każdego procesu widzi każdy lokalny użytkownik w ps aux i /proc/<pid>/cmdline. Adres podany wprost w curl "https://…/bot$TG_TOKEN/…" wystawiłby więc token na kilkadziesiąt sekund przy każdym getUpdates, a w ps widać tylko curl -s -K - -f -m 70 …. -G zamienia parametry z -d na query string, bo getUpdates to GET.
  • offset potwierdza Telegramowi odebrane wiadomości. Bez niego getUpdates przy każdym wywołaniu zwracałby te same wiadomości, a bot odpowiadałby na nie w kółko.
  • [ "$chat" = "$TG_CHAT" ] to cała autoryzacja. Nazwę bota da się znaleźć w wyszukiwarce Telegrama i każdy może do niego napisać. Bot odpowiada tylko na twój czat, a resztę po cichu pomija, więc nikt obcy nie dowie się nawet, że bot działa.
  • read -r cmd arg _ zamiast set -- $text dzieli wiadomość bez rozwijania wzorców powłoki. Wiadomość * nie zamieni się w listę plików z katalogu roboczego.
  • <pre> z parse_mode=HTML wyświetla odpowiedź czcionką o stałej szerokości, więc kolumny w status i top się nie rozjeżdżają. Znaki &, < i > trzeba zamienić na encje, inaczej Telegram odrzuci wiadomość z błędem parsowania.
  • raport przyjmuje od 1 do 14 dni, a inna wartość daje domyślne 7. Logrotate trzyma 14 dni logów, więc dłuższy okres pokazałby zera tam, gdzie logów już nie ma. 10#$arg zamienia 08 na 8, bo Bash czyta liczby z zerem na początku jako ósemkowe, a 08 jest błędem.
  • raport, Raport i /raport działają tak samo, bo ${text,,} zmienia litery na małe, a ${cmd#/} ucina ukośnik.

Usługa systemd bez roota

Bot musi czytać logi Apache'a (root:adm, 640) i token z /etc/telegram-alert.conf (root, 600). Nie musi do tego działać jako root:

bash
$sudo nano /etc/systemd/system/tg-bot.service
ini
[Unit]
Description=Bot Telegrama (komendy)
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/sbin/tg-bot
DynamicUser=yes
SupplementaryGroups=adm
LoadCredential=tg:/etc/telegram-alert.conf
TemporaryFileSystem=/var/log:ro
BindReadOnlyPaths=/var/log/apache2
InaccessiblePaths=-/run/log/journal
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
UMask=0077
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
  • DynamicUser=yes uruchamia proces jako użytkownik tworzony przy starcie usługi, bez konta w /etc/passwd i bez katalogu domowego.
  • SupplementaryGroups=adm daje prawo czytania logów Apache'a. Na Debianie grupa adm czyta jednak także resztę /var/log i cały journal, czyli logowania SSH, sudo i adresy IP z całego systemu. Dlatego TemporaryFileSystem=/var/log:ro podmienia botowi /var/log na pusty katalog, BindReadOnlyPaths wstawia do niego z powrotem tylko apache2/, a InaccessiblePaths ukrywa journal trzymany w RAM-ie. Minus przed ścieżką oznacza: pomiń, jeśli nie istnieje.
  • LoadCredential: systemd jako root czyta plik z tokenem i udostępnia jego kopię wyłącznie tej usłudze, w katalogu ze zmiennej $CREDENTIALS_DIRECTORY. Oryginał zostaje z prawami 600.
  • ProtectSystem=strict montuje cały system plików tylko do odczytu. Bot niczego nie zapisuje, a mktemp w www-report pisze do prywatnego /tmp z PrivateTmp.
  • Reszta (CapabilityBoundingSet= bez wartości, Protect*, Restrict*, SystemCallFilter=@system-service) odbiera procesowi wszystko, czego bash, curl, jq i awk nie potrzebują: uprawnienia roota, ładowanie modułów, zmianę zegara, przestrzenie nazw i nietypowe wywołania systemowe.

Celowo nie ma tu ProtectProc=invisible, choć zwykle należy do takiego zestawu. Ukryłby botowi procesy innych użytkowników i top pokazywałby tylko samego bota.

bash
$sudo systemctl daemon-reload
$sudo systemctl enable --now tg-bot
$systemd-analyze security tg-bot | tail -1
→ Overall exposure level for tg-bot.service: 1.7 OK 🙂
$systemctl status tg-bot --no-pager | head -3
● tg-bot.service - Bot Telegrama (komendy)
Loaded: loaded (/etc/systemd/system/tg-bot.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-10-23 11:02:14 CEST; 4s ago

systemd-analyze security ocenia izolację usługi w skali od 0 (pełna) do 10 (żadna). Domyślna usługa bez tych opcji dostaje 9,6 UNSAFE.

Napisz do bota status, a odpowiedź powinna przyjść w sekundę. Jeśli nie przychodzi, przyczynę pokaże journalctl -u tg-bot -n 30.

Po zarejestrowaniu komend Telegram podpowiada je po wpisaniu / i pokazuje przycisk menu obok pola wiadomości. Wystarczy jedno wywołanie API:

bash
$sudo bash -c '. /etc/telegram-alert.conf; curl -s -K - -H "Content-Type: application/json" \
-d "{\"commands\":[{\"command\":\"raport\",\"description\":\"Ruch dzień po dniu, 7 dni (raport 14 = 14 dni)\"},{\"command\":\"status\",\"description\":\"Load, RAM, swap, dysk\"},{\"command\":\"top\",\"description\":\"Procesy wg CPU i RAM\"}]}" \
<<< "url = \"https://api.telegram.org/bot$TG_TOKEN/setMyCommands\""'
{"ok":true,"result":true}

Komenda kliknięta w menu przychodzi jako /raport, a pętla bota obsługuje ją tak samo jak raport.

Bezpieczeństwo

Bot na serwerze, który odpowiada na wiadomości z internetu, brzmi ryzykownie. Ten jest jednak ograniczony z każdej strony: niczego nie wykonuje na żądanie, tylko odsyła liczby.

Co już jest zabezpieczone:

  • Brak otwartego portu. Long polling to połączenie wychodzące do api.telegram.org. Z internetu nie ma się do czego podłączyć, inaczej niż przy webhooku, który wymaga publicznego endpointu HTTPS.
  • Nie da się podszyć pod twój czat. chat.id nadaje Telegram, a aktualizacje przychodzą przez TLS w odpowiedzi na zapytanie z tokenem. Nikt nie wstrzyknie wiadomości „od ciebie”.
  • Brak wstrzyknięcia poleceń. Tekst wiadomości trafia tylko do case, nigdy do eval ani do powłoki. Argument raport musi być liczbą 1–14, a odpowiedź jest escapowana do HTML-a.
  • Token nie wycieka lokalnie. Nie ma go w ps (curl -K -), a plik z nim ma 600 i należy do roota.
  • Bot nie ma uprawnień. Działa bez roota, bez zapisu na dysk i widzi z /var/log tylko logi Apache'a. Gdyby ktoś przejął jego proces, może czytać logi WWW i wysyłać wiadomości jako bot, i nic więcej.
  • W odpowiedziach nie ma danych osobowych. Bot wysyła liczby, a nie adresy IP ani URL-e.

Co od ciebie zależy:

  • Konto Telegrama staje się kluczem do bota. Włącz w Telegramie hasło dwuetapowe (Ustawienia → Prywatność i bezpieczeństwo → Weryfikacja dwuetapowa). Bez niego do przejęcia konta wystarczy przechwycony SMS.
  • TG_CHAT musi być czatem prywatnym, a nie grupą. W grupie komendy może wydawać każdy jej członek.
  • Wyciek tokenu pozwala odczytywać twoje komendy, wysyłać ci fałszywe alerty w imieniu bota i przez forwardMessage przekazać sobie jego wcześniejsze odpowiedzi. Komend na serwerze nadal nie wykona. Token unieważnisz komendą /revoke u @BotFather, a nowy wpisz do /etc/telegram-alert.conf i zrestartuj usługę.
  • Wiadomości bota nie są szyfrowane end-to-end i przechodzą przez serwery Telegrama, więc Telegram widzi twoje statystyki i nazwy procesów. Przy tych danych to akceptowalne, ale nie dodawaj komend, które wysyłają fragmenty logów, konfigurację albo adresy IP.

Co się dzieje pod spodem

curl z timeout=60 wysyła zapytanie do Telegrama i czeka. Telegram trzyma połączenie otwarte, dopóki nie przyjdzie wiadomość albo nie minie 60 sekund. Bot przez większość czasu śpi na jednym otwartym połączeniu TCP i nie zużywa CPU. -m 70 w curl ustawia limit trochę dłuższy niż timeout, żeby zerwane połączenie nie zawiesiło pętli na zawsze. Po błędzie sieci pętla odczekuje 10 sekund i próbuje ponownie, a gdy cały skrypt się wywróci, systemd uruchomi go od nowa przez Restart=always.

Każda wiadomość ma rosnący update_id. Wysłanie offset = ostatni + 1 mówi Telegramowi: „wszystko do tego numeru mam”, a Telegram usuwa wtedy potwierdzone wiadomości u siebie. Nieodebrane wiadomości czekają u niego do 24 godzin, więc komenda wysłana podczas restartu serwera nie przepadnie, tylko dostaniesz odpowiedź zaraz po starcie bota.

Zobacz też