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:
| Komenda | Co 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) |
status | load average, RAM, swap, zajętość dysków |
top | 5 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.
$sudo nano /usr/local/sbin/www-report
#!/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
forbuduje 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=Cjest konieczne, bo w polskim localedatezwróciłbypaźzamiastOct.grep -F -fzostawia tylko linie z tych dni, więcawknie przegląda całego logu. zcat -fczyta tak samo zwykłe i spakowane logi, więc rotacja nie ma znaczenia.awkwycina 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
razemto unikalne adresy z całego okresu, a nie suma kolumny. Ktoś, kto wchodził codziennie, liczy się raz.
$sudo chmod 755 /usr/local/sbin/www-report$sudo www-report 7Ruch za ostatnie 7 dni (vps1)nowy-sklep.pldzień odsł. IP żądania google17.10 so 412 131 5210 31818.10 nd 388 120 4902 29719.10 pn 790 215 9871 40220.10 wt 845 233 10433 45521.10 śr 802 229 9985 38922.10 cz 771 210 9610 36123.10 pt 804 226 11229 512razem 4812 1307 61240 2734
Komenda status: obciążenie, pamięć, dysk
$sudo nano /usr/local/sbin/srv-status
#!/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 }'
$sudo chmod 755 /usr/local/sbin/srv-status$sudo srv-statusvps1, up 5 weeks, 6 hours, 10 minutesLoad: 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
$sudo nano /usr/local/sbin/srv-top
#!/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 : "") }'
$sudo chmod 755 /usr/local/sbin/srv-top$sudo srv-topCPU (pomiar 1 s):12.0% php-fpm8.43.0% mariadbd1.0% apache20.0% systemd0.0% kthreaddRAM (suma procesów):1104 MB mariadbd522 MB php-fpm8.4 ×9167 MB apache2 ×688 MB fail2ban-server41 MB systemd-journal
Dwie rzeczy, które łatwo zrobić źle:
- CPU z
psto ś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 wps0.3%. Dlatego CPU mierzytopw 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-fpmpo 60 MB wygląda niewinnie. Dopiero suma×9pokazuje, 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:
$sudo apt install jq$sudo nano /usr/local/sbin/tg-bot
#!/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//'&'/'&'}; t=${t//'<'/'<'}; t=${t//'>'/'>'}
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
$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 wps auxi/proc/<pid>/cmdline. Adres podany wprost wcurl "https://…/bot$TG_TOKEN/…"wystawiłby więc token na kilkadziesiąt sekund przy każdymgetUpdates, a wpswidać tylkocurl -s -K - -f -m 70 ….-Gzamienia parametry z-dna query string, bogetUpdatesto GET.offsetpotwierdza Telegramowi odebrane wiadomości. Bez niegogetUpdatesprzy 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 _zamiastset -- $textdzieli wiadomość bez rozwijania wzorców powłoki. Wiadomość*nie zamieni się w listę plików z katalogu roboczego.<pre>zparse_mode=HTMLwyświetla odpowiedź czcionką o stałej szerokości, więc kolumny wstatusitopsię nie rozjeżdżają. Znaki&,<i>trzeba zamienić na encje, inaczej Telegram odrzuci wiadomość z błędem parsowania.raportprzyjmuje 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#$argzamienia08na8, bo Bash czyta liczby z zerem na początku jako ósemkowe, a08jest błędem.raport,Raporti/raportdział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:
$sudo nano /etc/systemd/system/tg-bot.service
[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=yesuruchamia proces jako użytkownik tworzony przy starcie usługi, bez konta w/etc/passwdi bez katalogu domowego.SupplementaryGroups=admdaje prawo czytania logów Apache'a. Na Debianie grupaadmczyta jednak także resztę/var/logi cały journal, czyli logowania SSH,sudoi adresy IP z całego systemu. DlategoTemporaryFileSystem=/var/log:ropodmienia botowi/var/logna pusty katalog,BindReadOnlyPathswstawia do niego z powrotem tylkoapache2/, aInaccessiblePathsukrywa 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 prawami600.ProtectSystem=strictmontuje cały system plików tylko do odczytu. Bot niczego nie zapisuje, amktempwwww-reportpisze do prywatnego/tmpzPrivateTmp.- 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.
$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.
Menu komend w Telegramie
Po zarejestrowaniu komend Telegram podpowiada je po wpisaniu / i pokazuje przycisk menu obok pola wiadomości. Wystarczy jedno wywołanie API:
$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.idnadaje 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 doevalani do powłoki. Argumentraportmusi 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 ma600i należy do roota. - Bot nie ma uprawnień. Działa bez roota, bez zapisu na dysk i widzi z
/var/logtylko 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_CHATmusi 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
forwardMessageprzekazać sobie jego wcześniejsze odpowiedzi. Komend na serwerze nadal nie wykona. Token unieważnisz komendą/revokeu @BotFather, a nowy wpisz do/etc/telegram-alert.confi 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ż
- Zabezpieczenie serwera Linux od podstaw — skąd wziął się bot, token i self-check.
- Apache + PHP-FPM + MariaDB bezpiecznie i wydajnie — vhosty z osobnymi logami, z których korzysta
raport.