Zabezpieczenie serwera Linux od podstaw — uniwersalna procedura dla dowolnego VPS-a i dedyka
Sprawdzona, niezależna od dostawcy procedura domknięcia gołego serwera Linux: użytkownik i SSH, nftables z bezpiecznikiem, automatyczne łatki, banowanie i self-check. Każdy krok z weryfikacją i drogą odwrotu.
Problem
Nowy serwer — obojętnie, czy to VPS w Hetznerze, DigitalOcean, OVH, Linode, dedyk w szafie kolegi, czy maszyna wirtualna w firmowym Proxmoksie — dostajesz w stanie „działa, reszta to twój problem". Dostawca dał ci roota (albo ubuntu, debian, admin — zależy gdzie), coś nasłuchuje na 22, firewalla prawie na pewno nie ma, IPv6 prawie na pewno jest, choć o tym nie wiesz. Pierwsze skanowanie portu 22 przychodzi w ciągu minut od podniesienia maszyny, pierwsza próba logowania na root/admin/ubuntu w ciągu kwadransa.
Ten wpis to procedura domknięcia samego core'a systemu — bez Apache, Node, baz danych — napisana tak, żeby działała u dowolnego dostawcy. Nie zakłada, że masz panel z firewallem, cloud-init albo konsolę ratunkową; zakłada, że masz shell i roota. Wszystko, co zależy od dostawcy, jest zebrane w jednym miejscu na początku, a każdy krok ma trzy elementy: komendę, weryfikację i drogę odwrotu. Konfiguracja konkretnych stacków (PHP, Node, Python, Docker) to osobne wpisy.
Efekt końcowy: maszyna, na którą wejdziesz tylko ty, tylko kluczem; wszystko inne odrzucone na poziomie kernela; łatki lecą same; ataki są banowane automatycznie; a gdy coś się zepsuje — dostajesz powiadomienie zamiast abuse ticketu.
Założenia
Procedura jest napisana i przetestowana pod Debiana 13 i działa bez zmian na Ubuntu 24.04/26.04 LTS — dwa miejsca, gdzie się różnią, są zaznaczone. Na rodzinie RHEL (AlmaLinux, Rocky) logika jest ta sama, zmieniają się narzędzia — tabelę zamienników znajdziesz na końcu.
Jeśli masz wpływ na wybór obrazu: Debian 13, minimalny, bez „aplikacji" i paneli preinstalowanych przez dostawcę. Nie rolling (Arch, Tumbleweed), nie Fedora, nie CentOS Stream, nie non-LTS Ubuntu — to nie są cykle życia serwera.
Krok 0: rozpoznanie terenu
Zanim cokolwiek zmienisz, ustal cztery rzeczy. Każda z nich różni się między dostawcami i każda potrafi zepsuć dalsze kroki, jeśli ją założysz zamiast sprawdzić.
Jak wchodzisz i kim jesteś
$whoami$id$sudo -n true && echo "sudo bez hasła" || echo "sudo pyta o hasło lub go nie ma"
Warianty, które spotkasz:
- root po kluczu (Hetzner, OVH, większość dedyków) — najprostszy start.
- root po haśle z maila (część tańszych VPS-ów) — to hasło jest już skompromitowane przez sam fakt, że leży na skrzynce. Zmień je pierwszą komendą (
passwd), a i tak za chwilę je zablokujesz. - użytkownik z sudo bez hasła (
ubuntuna AWS/Ubuntu images,debianna GCP,adminu części dostawców) — root ma zablokowane hasło, wchodzisz przezsudo.
Co już nasłuchuje i jakie reguły już stoją
$sudo ss -tulpnH$sudo nft list ruleset$which ufw firewalld iptables 2>/dev/null$systemctl is-active ufw firewalld 2>/dev/null
Część obrazów ma preinstalowane ufw (Ubuntu, wyłączone) albo firewalld (RHEL, włączone), a część dostawców wstrzykuje własne reguły. Zapisz sobie wynik — będziesz go potrzebował w kroku 3, gdy zdecydujesz, co z tym zrobić.
IPv6
$ip -6 addr show scope global
Jeśli widzisz adres — masz publiczne IPv6 i każda reguła firewalla musi istnieć w dwóch wersjach. To najczęstsza dziura na „zabezpieczonych" serwerach: nftables domknięte po IPv4, a po IPv6 wszystko otwarte, bo o nim nikt nie pomyślał.
Wyjście awaryjne
Zanim zamkniesz sobie drzwi, sprawdź, że istnieje okno:
- Konsola dostawcy (VNC / serial / „Console" w panelu) — otwórz ją teraz i zaloguj się. Działa niezależnie od sieci i firewalla, ale zwykle wymaga hasła, nie klucza. Jeśli root ma zablokowane hasło, a ty nie masz jeszcze użytkownika — nie masz konsoli. Sprawdź też, czy hasło da się w niej wpisać — konsola przeglądarkowa często ma inny układ klawiatury (np. niemiecki u Hetznera), więc znaki specjalne wpisują się jako coś innego. Najbezpieczniej: długie hasło z samych liter i cyfr.
- Tryb ratunkowy (rescue) — dostawca bootuje maszynę z zewnętrznego obrazu i pozwala zamontować dysk. Wiedz, gdzie jest ten przycisk.
- Snapshot — jeśli dostawca go oferuje, zrób go teraz. Tanie cofnięcie każdego kroku poniżej.
Krok 1: klucz, użytkownik, sudo
Klucz generujesz na swoim komputerze, nigdy na serwerze:
$ssh-keygen -t ed25519 -a 100 -C "adm@laptop"
Ed25519, nie RSA. Jeśli masz klucz sprzętowy (YubiKey, Nitrokey) — -t ed25519-sk i klucz prywatny przestaje być plikiem, który można ukraść.
Na serwerze tworzysz własnego użytkownika administracyjnego — nawet jeśli dostawca dał ci już ubuntu czy admin. Nazwa znana z obrazu jest pierwszą, jaką sprawdzają boty.
$sudo groupadd -f sshusers$sudo useradd -m -s /bin/bash -G sudo,sshusers adm$sudo passwd adm$sudo install -d -m 700 -o adm -g adm /home/adm/.ssh$sudo install -m 600 -o adm -g adm /dev/null /home/adm/.ssh/authorized_keys$echo 'ssh-ed25519 AAAA... adm@laptop' | sudo tee -a /home/adm/.ssh/authorized_keys
useradd zamiast adduser, bo działa identycznie na każdej dystrybucji. Na rodzinie RHEL grupa nazywa się wheel, nie sudo.
Hasło ustaw prawdziwe (długie, z menedżera haseł) — jest potrzebne do sudo i do konsoli awaryjnej. sudo bez hasła na serwerze produkcyjnym to zamiana modelu „ukradziony klucz = shell użytkownika" na „ukradziony klucz = root".
Weryfikacja — z drugiego terminala:
$ssh adm@twoj-serwer$sudo -v
Wchodzi i sudo przyjmuje hasło? Dopiero teraz zablokuj roota i posprzątaj po dostawcy:
$sudo passwd -l root$sudo userdel -r ubuntu # albo debian / admin — jeśli taki istnieje i nie jest tobą
Hetzner Cloud z kluczem SSH wybranym przy tworzeniu
Gdy przy zakładaniu serwera wskażesz klucz SSH, Hetzner wpisuje go rootowi i nie ustawia hasła roota — logujesz się ssh root@IP, a konsola w panelu nie ma cię jak wpuścić, dopóki nie założysz użytkownika z hasłem. Innych kont (ubuntu, debian) nie ma. Zamiast wklejać klucz ręcznie, skopiuj go od roota:
$groupadd -f sshusers$useradd -m -s /bin/bash -G sudo,sshusers adm$passwd adm$install -d -m 700 -o adm -g adm /home/adm/.ssh$install -m 600 -o adm -g adm /root/.ssh/authorized_keys /home/adm/.ssh/authorized_keys
Weryfikacja jak wyżej — z drugiego terminala ssh adm@IP i sudo -v. Potem:
$sudo passwd -l root$sudo rm /root/.ssh/authorized_keys
Hasło adm od razu sprawdź w konsoli panelu (serwer → >_) — to twoje jedyne wyjście awaryjne.
Krok 2: uszczelnienie SSH
Kto trzyma port 22
Ubuntu 24.04+ domyślnie używa aktywacji przez socket — portem zarządza ssh.socket, nie sshd. Debian 13 zostaje przy ssh.service, ale po skryptach dostawców bywa różnie.
$systemctl is-enabled ssh.socket ssh.service 2>/dev/nulldisabledenabled$ss -lntp | grep -E ':22\b'LISTEN 0 128 0.0.0.0:22 0.0.0.0:*LISTEN 0 128 [::]:22 [::]:*
Tak wygląda typowy Debian 13: socket wyłączony, port trzyma ssh.service, sshd słucha na 22 po IPv4 i IPv6. Na Ubuntu zobaczysz odwrotnie (enabled / disabled) — to też działa, dopóki nie zmieniasz portu.
Kto już nadpisał konfigurację
To najważniejsza pułapka całego kroku. sshd bierze pierwszą napotkaną wartość każdej opcji, a pliki z sshd_config.d/ wczytuje alfabetycznie. Dostawcy i cloud-init lubią wrzucać tam własne pliki (50-cloud-init.conf, 60-provider.conf), które wygrają z twoim 99-hardening.conf, jeśli tak go nazwiesz.
$grep -n '^Include' /etc/ssh/sshd_config$ls -la /etc/ssh/sshd_config.d/$sudo sshd -T | grep -Ei 'passwordauth|permitroot|pubkey|kbdinteractive'
sshd -T pokazuje efektywną konfigurację po scaleniu wszystkiego — to jedyny wiarygodny sposób, żeby sprawdzić, co sshd naprawdę robi. Dlatego własny plik nazywasz tak, żeby był pierwszy:
$sudo nano /etc/ssh/sshd_config.d/00-hardening.conf
# Kto może wejść
PermitRootLogin no
AllowGroups sshusers
# Tylko klucz, bez furtek
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
PermitEmptyPasswords no
# Limity prób i czasu
MaxAuthTries 3
MaxSessions 5
MaxStartups 10:30:60
LoginGraceTime 20
# Sprzątanie martwych sesji
ClientAliveInterval 300
ClientAliveCountMax 2
TCPKeepAlive no
# Wyłączone, czego nie używasz
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
GatewayPorts no
PermitUserEnvironment no
# Logi z nazwami kluczy — kluczowe przy audycie
LogLevel VERBOSE
# Tylko klucz hosta Ed25519 — aktualizacja pakietu potrafi odtworzyć RSA/ECDSA, ale nie będą używane
HostKey /etc/ssh/ssh_host_ed25519_key
# Domyślne algorytmy minus słabe — "-" usuwa z listy domyślnej, reszty nie ruszamy
MACs -*sha1*,umac-64*,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512
KexAlgorithms -ecdh-sha2-nistp*
# ~/.ssh/config
Host twoj-serwer
HostName IP-serwera
User adm
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Jeśli plik sshd_config nie ma linii Include (starsze obrazy, niektóre dedyki), dodaj ją na samej górze pliku — nie na dole, bo wtedy własna konfiguracja przegra ze wszystkim, co jest wyżej.
Walidacja, restart, weryfikacja — w tej kolejności, bez skrótów:
$sudo sshd -t && echo SKŁADNIA-OK$sudo sshd -T | grep -Ei '^(passwordauthentication|permitrootlogin|allowgroups)'$sudo systemctl restart ssh
Z nowego terminala (stary trzymasz otwarty):
$ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no adm@twoj-serweradm@twoj-serwer: Permission denied (publickey).
Taki błąd to sukces. Potem normalne ssh adm@twoj-serwer — musi wejść. Dopiero teraz zamykasz starą sesję.
Klucze hosta i kryptografia
OpenSSH 9.x/10 ma sensowne domyślne algorytmy — nie wpisuj ręcznie list szyfrów skopiowanych z blogów sprzed lat, bo potrafią wyłączyć nowszą, lepszą kryptografię (np. wymianę kluczy odporną na komputer kwantowy, którą OpenSSH 10 włącza domyślnie). Wyjątki są dwa i są już w konfiguracji wyżej: MACs -… usuwa MAC-i na SHA-1, z krótkim 64-bitowym tagiem i te bez trybu -etm (encrypt-then-MAC), a KexAlgorithms -ecdh-sha2-nistp* wymianę kluczy na krzywych NIST — jedno i drugie OpenSSH trzyma tylko dla zgodności ze starymi klientami. Prefiks - znaczy „domyślne minus te", więc nowsze algorytmy z przyszłych wersji dalej wejdą same. Druga rzecz to zostawienie samego klucza hosta Ed25519:
$sudo rm -f /etc/ssh/ssh_host_rsa_key* /etc/ssh/ssh_host_ecdsa_key* /etc/ssh/ssh_host_dsa_key*$sudo systemctl restart ssh
Weryfikacja:
$sudo sshd -T | grep -E '^(macs|hostkey|kexalgorithms)'hostkey /etc/ssh/ssh_host_ed25519_keykexalgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256macs umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com
W macs zostają same -etm, w kexalgorithms żadnego nistp. Z zewnątrz, z laptopa:
$ssh-audit twoj-serwer
Bez [fail], a przy MAC-ach same [info].
Zmiana portu — opcjonalna
Port 2222 nie jest zabezpieczeniem — to redukcja szumu w logach. Jeśli zostajesz na 22, pomiń tę sekcję.
Zmieniasz port teraz, póki firewalla jeszcze nie ma — w kroku 3 wpiszesz od razu nowy port zamiast 22.
1. Wyłącz aktywację przez socket — inaczej po reboocie ssh.socket potrafi z powrotem zająć port 22:
$sudo systemctl disable --now ssh.socket$sudo rm -rf /etc/systemd/system/ssh.socket.d$sudo systemctl daemon-reload$sudo systemctl enable ssh.service
2. Ustaw port w tym samym pliku co wyżej:
$sudo nano /etc/ssh/sshd_config.d/00-hardening.conf
Dopisz na początku pliku:
Port 2222
Nie dodawaj ListenAddress — sshd ma słuchać na wszystkich adresach.
3. Zastosuj i sprawdź:
$sudo sshd -t && sudo systemctl restart ssh$sudo ss -lntp | grep sshdLISTEN 0 128 0.0.0.0:2222 0.0.0.0:* users:(("sshd",...))LISTEN 0 128 [::]:2222 [::]:* users:(("sshd",...))$systemctl is-enabled ssh.socket ssh.servicedisabledenabled
4. Test z nowego terminala — stara sesja zostaje otwarta, dopóki nowa nie wejdzie:
$ssh -p 2222 adm@twoj-serwer
Od teraz port 2222 wpisujesz wszędzie, gdzie wpis mówi o 22: w regułach nftables (krok 3) i w jail.local fail2bana (krok 7).
Krok 3: firewall z bezpiecznikiem
Decyzja: co z tym, co już jest
Wracasz do wyniku z kroku 0:
- Nic nie ma (goły Debian, większość VPS-ów) — instalujesz
nftablesi piszesz reguły wprost. ufwzainstalowane, nieaktywne (Ubuntu) — albo używaszufw(prostsze, ale to nakładka, która czasem kłamie), albo go usuwasz:sudo apt purge ufw. Nie trzymaj obu.firewalldaktywne (RHEL) — zostań przy nim, to ten sam nftables pod spodem; reguły z tego wpisu przetłumaczysz nafirewall-cmd.- Reguły dostawcy w
nft list ruleset— zwykle dotyczą metadanych chmury albo sieci prywatnej; nie kasuj ich w ciemno, tylko sprawdź, skąd się biorą (grep -r nft /etc/systemd /etc/network /etc/cloud 2>/dev/null).
Dalej zakładam wariant pierwszy.
Reguły
$sudo apt install nftables$sudo cp /etc/nftables.conf /etc/nftables.conf.bak 2>/dev/null$sudo nano /etc/nftables.conf
#!/usr/sbin/nft -f
# usuń starą wersję tej tabeli i zbuduj ją od nowa poniżej;
# tabel innych programów (np. fail2bana) nie ruszaj
table inet filter # utwórz, jeśli nie istnieje — inaczej delete wywali błąd
delete table inet filter
table inet filter {
set ssh_flood_v4 {
type ipv4_addr
flags dynamic, timeout
timeout 1h
}
set ssh_flood_v6 {
type ipv6_addr
flags dynamic, timeout
timeout 1h
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
# ICMP — potrzebne do PMTU i diagnostyki, z limitem
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded, parameter-problem } limit rate 10/second accept
# ICMPv6 — bez tego IPv6 przestaje działać (neighbor discovery)
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } accept
# DHCP — część dostawców konfiguruje interfejs dynamicznie
udp sport 67 udp dport 68 accept
udp sport 547 udp dport 546 accept
# SSH z limitem 6 nowych połączeń / minutę / adres
tcp dport 22 ct state new add @ssh_flood_v4 { ip saddr limit rate over 6/minute } drop
tcp dport 22 ct state new add @ssh_flood_v6 { ip6 saddr limit rate over 6/minute } drop
tcp dport 22 ct state new accept
# reszta w kosz, ale policzona i (z limitem) zalogowana
limit rate 5/minute log prefix "nft-drop: " level info
counter
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Jeśli SSH jest na innym porcie — zmień 22 w trzech miejscach. Jeśli serwer ma sieć prywatną, przez którą rozmawia z innymi maszynami, dodaj iifname "ens10" accept (nazwę interfejsu z ip link) nad regułami SSH.
Po każdej zmianie pliku przeładowuj reguły przez sudo systemctl reload nftables, nie restart — zatrzymanie usługi czyści cały ruleset, razem z banami z kroku 7.
Bezpiecznik
Zanim włączysz reguły — jedna komenda, która za 5 minut wyczyści cały firewall, jeśli sam jej nie odwołasz. Nie ma znaczenia, u jakiego dostawcy jesteś i czy ma konsolę ratunkową — to działa wszędzie, gdzie jest systemd:
$sudo systemd-run --on-active=5m --unit=fw-rollback /usr/sbin/nft flush rulesetRunning timer as unit: fw-rollback.timerWill run service as unit: fw-rollback.service$sudo nft -c -f /etc/nftables.conf && echo SKŁADNIA-OKSKŁADNIA-OK$sudo nft -f /etc/nftables.conf
Bez SKŁADNIA-OK nie ładuj reguł — nft wypisze numer linii z błędem.
Teraz z nowego terminala:
$ssh adm@twoj-serwer
Wchodzi? Odwołaj bezpiecznik i utrwal:
$sudo systemctl stop fw-rollback.timer$sudo systemctl enable --now nftables
Nie wchodzi? Nic nie rób. Za pięć minut reguły znikną, stara sesja dalej działa, poprawiasz plik i powtarzasz.
Weryfikacja:
$sudo nft list ruleset | head -40$sudo nft list set inet filter ssh_flood_v4$systemctl is-enabled nftablesenabled
Co ma być w wyniku:
nft list ruleset—table inet filterz łańcucheminputipolicy drop;, poniżej twoje reguły. Jeśli widzisz też tabelęf2b-table(fail2ban) — to normalne.nft list set … ssh_flood_v4— definicja zbioru (type ipv4_addr,flags dynamic,timeout,timeout 1h) bez liniielements. Pusty zbiór znaczy, że nikt jeszcze nie przekroczył limitu; adresy pojawią się tu same, gdy boty zaczną pukać.is-enabled—enabled; cokolwiek innego i firewall nie wstanie po reboocie.
Ostatnia linia jest ważniejsza, niż wygląda. Firewall, który nie wstaje po reboocie, to firewall, którego nie masz — a dowiesz się o tym przy pierwszej automatycznej aktualizacji kernela o 4:30.
Warstwa dostawcy — jeśli istnieje
Jeżeli dostawca ma własny firewall (Hetzner Cloud, DigitalOcean, AWS Security Groups, OVH, Scaleway) — użyj go dodatkowo, nie zamiast. Działa przed maszyną, nie da się go wyłączyć ze skompromitowanego systemu i broni cię również wtedy, gdy popsujesz nftables. Minimalny zestaw: wejście na port SSH z twoich adresów (albo z całego świata, jeśli nie masz stałego IP), reszta odrzucona, wyjście otwarte.
Jeśli dostawca nie ma nic — nic nie tracisz; cały ten wpis jest napisany tak, żeby nftables wystarczyło samo.
Krok 4: redukcja powierzchni ataku
$sudo ss -tulpnH | awk '{print $1, $5, $7}'
Na minimalnym Debianie zobaczysz sshd i ewentualnie systemd-resolved na 127.0.0.53. Wszystko inne wymaga wyjaśnienia.
dhcpcd na UDP 68 (i 546 przy IPv6) to klient DHCP — Debian 13 używa go zamiast starego dhclient. Jest potrzebny, jeśli interfejs dostaje adres dynamicznie, co u większości dostawców jest normą; reguły z kroku 3 i tak wpuszczają na te porty tylko odpowiedzi serwera DHCP. Sprawdź:
$ip -4 addr show scope global | grep dynamic
Jest dynamic — zostaw dhcpcd w spokoju. Wyłączysz go, a przy odnowieniu dzierżawy serwer straci adres i sieć.
Typowi podejrzani, zależnie od obrazu:
$sudo systemctl disable --now exim4 2>/dev/null # MTA$sudo systemctl disable --now postfix 2>/dev/null # MTA$sudo apt purge rpcbind avahi-daemon 2>/dev/null # NFS-RPC, mDNS — bezużyteczne na serwerze publicznym$sudo systemctl disable --now snapd 2>/dev/null # Ubuntu; jeśli nie używasz snapów
Sprawdź też agentów dostawcy — część obrazów ma preinstalowane narzędzia monitorujące (qemu-guest-agent, cloud-init, agenty DO/AWS/Google). qemu-guest-agent i cloud-init zostaw — bez nich część funkcji panelu (snapshoty, reset hasła, resize) przestaje działać. Agentów monitoringu usuń, jeśli świadomie ich nie chcesz.
Kontrola MAC i uprawnień usług:
$sudo aa-status --summary # Debian/Ubuntu: AppArmor ma być loaded$systemd-analyze security --no-pager | sort -k2 -n -r | head
Drugie polecenie ocenia każdą usługę 0–10. Nie goń za zerami; szukaj usług z oceną UNSAFE, których nie rozpoznajesz.
Krok 5: parametry kernela
$sudo nano /etc/sysctl.d/99-hardening.conf
# --- sieć ---
# wzorzec * obejmuje all, default i każdy istniejący interfejs
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.*.rp_filter = 1
net.ipv4.conf.*.accept_redirects = 0
net.ipv4.conf.*.send_redirects = 0
net.ipv4.conf.*.accept_source_route = 0
net.ipv4.conf.*.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv6.conf.*.accept_redirects = 0
net.ipv6.conf.*.accept_source_route = 0
# --- kernel ---
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
kernel.yama.ptrace_scope = 1
kernel.sysrq = 0
# na konsolę tylko błędy — logi nft-drop nie zaśmiecają konsoli awaryjnej
kernel.printk = 3 4 1 3
# --- system plików ---
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0
$sudo systemctl restart systemd-sysctl
Działa od razu, bez restartu serwera.
Krok 6: aktualizacje, które lecą same
Najważniejszy krok całego wpisu. Serwery nie padają przez sprytne 0-day, tylko przez łatkę sprzed czterech miesięcy, której nikt nie wgrał.
$sudo apt install unattended-upgrades needrestart apt-listchanges$sudo nano /etc/apt/apt.conf.d/52unattended-upgrades-local
Wersja dla Debiana:
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
"origin=Debian,codename=${distro_codename}-updates";
};
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
Wersja dla Ubuntu — zamiast bloku Origins-Pattern wstaw ten, pozostałe linie bez zmian:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Cykl i restart usług po aktualizacji bibliotek (bez tego łatka leży na dysku, a w pamięci dalej działa dziurawy libssl):
$sudo tee /etc/apt/apt.conf.d/20auto-upgrades <<'EOF' APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; APT::Periodic::AutocleanInterval "7"; EOF$echo "\$nrconf{restart} = 'a';" | sudo tee /etc/needrestart/conf.d/50-autorestart.conf
Weryfikacja — nie zakładaj, że działa, sprawdź:
$sudo unattended-upgrade --dry-run --debug 2>&1 | grep -E 'Allowed origins|Packages that will be upgraded|No packages'$systemctl list-timers apt-daily-upgrade.timer --no-pager
W Allowed origins muszą pojawić się niepuste wpisy. Puste = literówka w origin i nic nigdy się nie zaktualizuje, po cichu.
Krok 7: serwer, który się sam pilnuje
Banowanie: fail2ban albo CrowdSec — nie oba
fail2ban banuje tych, którzy zaatakowali ciebie. CrowdSec dodatkowo pobiera listę adresów, które zaatakowały kogoś innego, i blokuje je z wyprzedzeniem. Na pojedynczy serwer wystarczy fail2ban; na kilka maszyn albo gdy chcesz prewencji — CrowdSec. Zainstalowane razem będą sobie nawzajem czyścić reguły.
$sudo apt install fail2ban python3-systemd$sudo nano /etc/fail2ban/jail.local
[DEFAULT]
backend = systemd
banaction = nftables[type=allports]
banaction_allports = nftables[type=allports]
findtime = 10m
maxretry = 4
bantime = 1h
bantime.increment = true
bantime.factor = 4
bantime.maxtime = 1w
ignoreip = 127.0.0.1/8 ::1 TWOJE.STALE.IP
[sshd]
enabled = true
mode = aggressive
# jeśli SSH jest na innym porcie:
# port = 2222
$sudo systemctl enable --now fail2ban$sudo fail2ban-client status sshd
bantime.increment to najbardziej niedoceniana opcja: uparty bot dostaje kolejno godzinę, cztery, szesnaście, a w końcu tydzień — bez twojego udziału.
Wariant CrowdSec — zamiast fail2bana, nie obok
$sudo apt install curl$curl -s https://install.crowdsec.net | sudo sh$sudo apt install crowdsec crowdsec-firewall-bouncer-nftables$sudo cscli metrics
Alert przy każdym logowaniu SSH
Powiadomienia idą przez bota na Telegramie — darmowe, bez własnego serwera. Jednorazowo:
- W Telegramie napisz do
@BotFather, wyślij/newbot— dostaniesz token. - Napisz cokolwiek do swojego nowego bota.
- Odczytaj swoje
chat_id:
$sudo apt install curl$curl -s "https://api.telegram.org/bot<TOKEN>/getUpdates" | grep -o '"chat":{"id":[0-9-]*'
Token i chat_id trzymasz w jednym pliku, z którego korzystają oba skrypty poniżej:
$sudo install -m 600 /dev/null /etc/telegram-alert.conf$sudo nano /etc/telegram-alert.conf
TG_TOKEN="123456:ABC-twoj-token"
TG_CHAT="123456789"
$sudo nano /usr/local/bin/ssh-alert.sh
#!/bin/bash
[ "$PAM_TYPE" = "open_session" ] || exit 0
. /etc/telegram-alert.conf
curl -s -m 5 "https://api.telegram.org/bot$TG_TOKEN/sendMessage" \
--data-urlencode "chat_id=$TG_CHAT" \
--data-urlencode "text=SSH: $PAM_USER z $PAM_RHOST na $(hostname) o $(date '+%H:%M %d.%m.%Y')" > /dev/null
exit 0
$sudo chmod 700 /usr/local/bin/ssh-alert.sh$echo 'session optional pam_exec.so seteuid /usr/local/bin/ssh-alert.sh' | sudo tee -a /etc/pam.d/sshd
Zaloguj się z drugiego terminala — powiadomienie musi przyjść. optional jest kluczowe: gdyby Telegram nie odpowiadał, logowanie i tak przejdzie.
Codzienny self-check
Jeden skrypt, który odzywa się tylko wtedy, gdy jest problem. Powiadomienia przychodzące codziennie przestaje się czytać po tygodniu.
$sudo nano /usr/local/bin/server-selfcheck.sh
#!/bin/bash
set -uo pipefail
. /etc/telegram-alert.conf
ISSUES=()
[ -f /var/run/reboot-required ] && ISSUES+=("Wymagany restart (nowy kernel)")
USE=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "$USE" -gt 85 ] && ISSUES+=("Dysk / zajęty w ${USE}%")
FAILED=$(systemctl list-units --state=failed --no-legend --plain | wc -l)
[ "$FAILED" -gt 0 ] && ISSUES+=("Usługi failed: $FAILED")
SEC=$(apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null | grep -ci '^inst.*security')
[ "$SEC" -gt 0 ] && ISSUES+=("Zaległe łatki bezpieczeństwa: $SEC")
# firewall nadal stoi?
nft list table inet filter >/dev/null 2>&1 || ISSUES+=("BRAK tabeli firewalla inet filter!")
# zmiana listy nasłuchujących portów = jeden z najprostszych sygnałów kompromitacji
PORTS=$(ss -tulnH | awk '{print $5}' | sed 's/.*://' | sort -u | tr '\n' ' ')
STORE=/var/lib/server-selfcheck.ports
if [ -f "$STORE" ] && [ "$PORTS" != "$(cat $STORE)" ]; then
ISSUES+=("Zmiana portów: było [$(cat $STORE)] jest [$PORTS]")
fi
echo "$PORTS" > "$STORE"
BANS=$(fail2ban-client status sshd 2>/dev/null | grep -oP 'Currently banned:\s+\K\d+')
[ "${BANS:-0}" -gt 50 ] && ISSUES+=("Aktywnych banów SSH: $BANS")
if [ ${#ISSUES[@]} -gt 0 ]; then
curl -s -m 10 "https://api.telegram.org/bot$TG_TOKEN/sendMessage" \
--data-urlencode "chat_id=$TG_CHAT" \
--data-urlencode "text=⚠ $(hostname)
$(printf '%s\n' "${ISSUES[@]}")" > /dev/null
fi
Uruchamiany jako root z timera systemd — trwały plik, nie jednorazowe systemd-run, żeby przeżył reboot i był widoczny w list-timers:
$sudo chmod 700 /usr/local/bin/server-selfcheck.sh$sudo tee /etc/systemd/system/selfcheck.service <<'EOF' [Unit] Description=Daily server self-check [Service] Type=oneshot ExecStart=/usr/local/bin/server-selfcheck.sh EOF$sudo tee /etc/systemd/system/selfcheck.timer <<'EOF' [Unit] Description=Run selfcheck daily [Timer] OnCalendar=*-*-* 07:00:00 Persistent=true [Install] WantedBy=timers.target EOF$sudo systemctl daemon-reload$sudo systemctl enable --now selfcheck.timer$sudo systemctl start selfcheck.service && echo "pierwsze uruchomienie OK"
Pierwsze uruchomienie zapisze bazową listę portów. Od jutra każda zmiana tej listy — nowy port, którego nie otwierałeś — przyjdzie do ciebie rano.
Krok 8: logi, które przeżyją restart
Nie zakładaj, że dziennik jest trwały — wymuś to i ustaw limity. Dziennik w RAM znika przy reboocie, czyli dokładnie wtedy, gdy jest potrzebny.
$sudo mkdir -p /etc/systemd/journald.conf.d$sudo tee /etc/systemd/journald.conf.d/00-persist.conf <<'EOF' [Journal] Storage=persistent SystemMaxUse=500M MaxRetentionSec=1month Compress=yes EOF$sudo systemctl restart systemd-journald$journalctl --disk-usage$ls -d /var/log/journal/*/ # katalog musi istnieć
Kontrola integralności plików (AIDE) jest opcjonalna — sens ma tylko, jeśli będziesz czytał raporty i odróżniał „zmiany po apt upgrade" od „ktoś podmienił /usr/bin/ssh". Nieczytany AIDE daje fałszywe poczucie bezpieczeństwa, które jest gorsze niż jego brak.
Krok 9: reboot i weryfikacja końcowa
Zrób to teraz, świadomie, a nie za dwa tygodnie o 4:30 przez unattended-upgrades:
$sudo reboot
Zaloguj się ponownie — na Telegram musi przyjść alert o logowaniu. Potem sprawdź, czy wszystkie mechanizmy wstały same.
Usługi — pięć razy active (z CrowdSec zamiast fail2bana wpisz crowdsec):
$systemctl is-active nftables ssh fail2ban selfcheck.timer apt-daily-upgrade.timeractiveactiveactiveactiveactive
Firewall — dwa łańcuchy z policy drop (input i forward):
$sudo nft list ruleset | grep -c 'policy drop'2
SSH — logowanie hasłem i na roota wyłączone:
$sudo sshd -T | grep -Ei '^(passwordauthentication|permitrootlogin)'permitrootlogin nopasswordauthentication no
Parametry kernela z kroku 5 — wynik ma być pusty:
$sysctl -a 2>/dev/null | grep -E '\.(accept_redirects|send_redirects|accept_source_route) = 1'
Co nasłuchuje:
$sudo ss -tulpn
Na liście mają być tylko: sshd na 0.0.0.0:22 i [::]:22 (albo twój port), dhcpcd na UDP 68 (i ewentualnie 546), a na adresach 127.0.0.x / [::1] lokalne usługi typu systemd-resolved czy chronyd. Cokolwiek innego na 0.0.0.0 albo [::] — wróć do kroku 4.
Ostrzeżenia z bieżącego bootu:
$sudo journalctl -b -p warning --no-pager | tail -20
Pojedyncze ostrzeżenia kernela o sprzęcie wirtualnym (ACPI, virtio) to szum. Czerwona flaga to Failed to start …, błędy nftables, sshd albo fail2ban.
Audyt z zewnątrz
Z innej maszyny, po obu protokołach. Uruchamiaj z adresu wpisanego w ignoreip fail2bana albo z innej sieci — ssh-audit celowo negocjuje algorytmy, których serwer nie obsługuje, a fail2ban w trybie aggressive liczy to jako atak i potrafi zbanować twoje IP.
$ssh-audit twoj-serwer # przy zmienionym porcie: ssh-audit -p 2222 twoj-serwer
Wszystkie sekcje na zielono, bez [fail]. W host-key tylko ssh-ed25519, w kex na górze mlkem768x25519-sha256. Pojedyncze [info] to nie problem.
$nmap -Pn -p- --min-rate 1000 twoj-serwerPORT STATE SERVICE22/tcp open ssh$nmap -6 -Pn -p- twoj-serwerPORT STATE SERVICE22/tcp open ssh
Nad tabelą zobaczysz Not shown: 65534 filtered tcp ports (no-response) — filtered znaczy, że firewall odrzuca po cichu, tak ma być. Otwarty ma być tylko port SSH. Skan -6 ma sens tylko z maszyny, która sama ma IPv6 — bez tego wszystko wyjdzie jako niedostępne i nic z tego nie wynika.
Pełny audyt systemu
$sudo apt install lynis && sudo lynis audit system
Na końcu raportu Hardening index — po tej procedurze typowo w okolicach 70–80. Nie goń za setką — część sugestii jest bez sensu na VPS-ie (hasło do GRUB-a), a część zepsuje działające rzeczy. Czytaj i wybieraj świadomie.
Checklista
- [ ] Sprawdzone: kim wchodzę, co nasłuchuje, czy jest IPv6, czy działa konsola awaryjna
- [ ] Snapshot przed zmianami (jeśli dostawca go ma)
- [ ] Własny użytkownik z
sudona hasło, root zablokowany, konto dostawcy usunięte lub zablokowane - [ ] Logowanie wyłącznie kluczem Ed25519;
sshd -Tpotwierdzapasswordauthentication no - [ ] Drop-in SSH nazwany tak, żeby był pierwszy w
sshd_config.d/ - [ ]
nftableszpolicy drop, enabled, reguły w wersji IPv4 i IPv6 - [ ] Bezpiecznik
systemd-runużyty, nowe połączenie przetestowane - [ ] Firewall dostawcy jako dodatkowa warstwa — jeśli istnieje
- [ ]
unattended-upgradesz niepustymAllowed originsw--dry-run - [ ]
needrestartautomatyczny, okno restartu ustawione - [ ] fail2ban albo CrowdSec z rosnącym czasem bana
- [ ] Alert o logowaniu SSH — przetestowany
- [ ] Self-check na trwałym timerze — uruchomiony raz ręcznie
- [ ] Journald
persistent - [ ] Świadomy reboot i wszystko wstało samo
- [ ] Backup poza tym serwerem — snapshot dostawcy nim nie jest
Zamienniki dla rodziny RHEL
| Krok | Debian / Ubuntu | AlmaLinux / Rocky |
|---|---|---|
| Grupa sudo | sudo | wheel |
| Restart SSH | systemctl restart ssh | systemctl restart sshd |
| Firewall | nftables wprost | firewalld (firewall-cmd --permanent --add-service=ssh) |
| Auto-aktualizacje | unattended-upgrades | dnf-automatic (apply_updates = yes w /etc/dnf/automatic.conf) |
| Restart usług po łatce | needrestart | dnf needs-restarting -r |
| fail2ban | w repo | z EPEL |
| MAC | AppArmor | SELinux — zostaw w enforcing |
| SSH, sysctl, journald, self-check | bez zmian | bez zmian |
Co się dzieje pod spodem
Wszystko powyżej sprowadza się do trzech zasad.
Warstwy, nie mury. Firewall dostawcy, nftables, konfiguracja sshd i fail2ban chronią przed tym samym atakiem na czterech poziomach. Każdą z tych warstw z osobna da się obejść albo popsuć — sens ma to, że przełamanie jednej nie daje niczego, dopóki stoją pozostałe.
Domyślna odmowa. policy drop, AllowGroups zamiast listy zablokowanych, AuthenticationMethods publickey zamiast wyłączania metod po kolei. Wymieniasz to, co dozwolone, i odrzucasz resztę — bo listy rzeczy zakazanych są zawsze niekompletne, a nie wiesz, co pojawi się w systemie za rok.
Automat zamiast dyscypliny. Serwer, który wymaga, żebyś co tydzień się logował i sprawdzał aktualizacje, jest niezabezpieczony — po prostu jeszcze o tym nie wiesz. Łatki instalują się same, bany rosną same, firewall wstaje sam po reboocie, a jedyne, co robisz, to czytasz powiadomienie, gdy przyjdzie.
I zasada zerowa, która robi z tej procedury coś pewnego, a nie tylko poprawnego: każda zmiana ma weryfikację i drogę odwrotu. Druga sesja SSH, sshd -t przed restartem, sshd -T po nim, systemd-run przed firewallem, --dry-run przed aktualizacjami, świadomy reboot na końcu. Nie dlatego, że komendy są trudne — dlatego, że jedna literówka w każdym z tych miejsc kosztuje godzinę z konsolą ratunkową, a te dziesięć sekund sprawdzenia kosztuje dziesięć sekund.