Projekt 1: nowy serwer w dziesięć minut
Po tej lekcji: kursant ma własny skrypt i własny playbook Ansible, które ze świeżego serwera robią serwer gotowy do pracy: konto administratora, SSH tylko na klucze, zapora, fail2ban i automatyczne poprawki bezpieczeństwa, na Ubuntu i na RHEL.
Wstęp
Klikasz „utwórz serwer”. Po minucie masz adres IP i hasło roota w mailu. Idziesz zrobić kawę. Wracasz po kwadransie, zaglądasz do logów:
sudo journalctl -u ssh --since '15 min ago' | grep -c 'Failed password'I widzisz kilkadziesiąt, a czasem kilkaset nieudanych prób logowania. Na roota, na admin, na test, na oracle. Nikt Cię nie zna, nikt nie wie, że ten serwer istnieje. Ale automaty skanujące internet wiedzą o nim po kilku minutach. To jak wprowadzenie się do nowego mieszkania z kartką na drzwiach „klucz pod wycieraczką”, w dzielnicy, gdzie złodzieje sprawdzają wszystkie wycieraczki po kolei, przez całą dobę.
Dlatego pierwsze dziesięć minut życia serwera to zawsze ta sama lista. Dziś ją zautomatyzujemy dwa razy. Najpierw jako skrypt w Bashu, bo to umiesz. Potem jako playbook Ansible, bo tak się to robi, gdy serwerów jest więcej niż palców jednej ręki. I na koniec porównamy, co jest lepsze. Spoiler: oba, ale do czego innego.
Plan, czyli lista zadań przed przeprowadzką
Zanim cokolwiek napiszesz, rozpisz kroki. Skrypt to przepis kulinarny, a przepisu nie pisze się w trakcie smażenia.
- Aktualizacje i pakiety. Najpierw łaty, bo obraz systemu od dostawcy ma czasem kilka miesięcy.
- Konto administratora w grupie, która ma sudo: [Ubuntu]
sudo, [RHEL]wheel. - Klucz SSH dla tego konta, czyli kłódka na serwerze, do której kluczyk masz tylko Ty.
- SSH tylko na klucze, bez haseł i bez logowania roota.
- Zapora: wpuszczamy tylko SSH. [Ubuntu]
ufw, [RHEL]firewalld. - fail2ban i automatyczne poprawki bezpieczeństwa.
Kolejność nie jest przypadkowa. Krok 3 musi być przed krokiem 4. Jeśli wyłączysz hasła, zanim położysz klucz, właśnie zamknąłeś się na zewnątrz, a klucze zostawiłeś w środku. To jak piłowanie gałęzi, na której siedzisz, tylko gałąź kosztuje 20 złotych miesięcznie, a dostęp do konsoli awaryjnej dostawcy znajdziesz o drugiej w nocy po pół godzinie szukania.
Ostrzeżenie, zanim zaczniemy. Ten skrypt zmienia konfigurację SSH i włącza zaporę. Testuj go na maszynie wirtualnej, a na prawdziwym serwerze zawsze miej otwartą drugą sesję SSH, której nie zamykasz, dopóki w trzeciej nie zalogujesz się nowym sposobem.
Skrypt: pełny kod
Na swoim komputerze, nie na serwerze, zrób parę kluczy, jeśli jeszcze jej nie masz, i wyślij na serwer klucz publiczny razem ze skryptem. Pamiętasz z lekcji 76:
ssh-keygen -t ed25519 -C 'eryk@laptop'
scp ~/.ssh/id_ed25519.pub nowy-serwer.sh root@ADRES_SERWERA:/root/A oto cały skrypt. Przeczytaj go raz od góry do dołu, a potem przejdziemy przez ciekawsze kawałki.
#!/usr/bin/env bash
# nowy-serwer.sh — pierwsze 10 minut życia serwera
# Ubuntu/Debian oraz RHEL/Rocky/Alma. Uruchamiać jako root.
set -euo pipefail
ADMIN=""
KLUCZ=""
PROBA=0
BEZ_AKTUALIZACJI=0
DROPIN=/etc/ssh/sshd_config.d/10-nowy-serwer.conf
pomoc() {
cat <<EOF
Użycie: sudo $0 -u UŻYTKOWNIK -k KLUCZ.pub [-n] [-s]
-u konto administratora, które powstanie (albo już jest)
-k plik z kluczem publicznym SSH tego administratora
-n próba: pokaż, co byłoby zrobione, niczego nie zmieniaj
-s pomiń aktualizację systemu (np. gdy robisz ją osobno)
-h ta pomoc
EOF
}
log() { printf '[%s] %s\n' "$(date +%T)" "$*"; logger -t nowy-serwer -- "$*" 2>/dev/null || true; }
blad() { printf 'BŁĄD: %s\n' "$*" >&2; exit 1; }
# zrob KOMENDA... — wykonaj albo, w trybie próby, tylko wypisz
zrob() {
if (( PROBA )); then printf ' [próba] %s\n' "$*"; else "$@"; fi
}
# zapisz PLIK — treść ze standardowego wejścia
zapisz() {
if (( PROBA )); then
printf ' [próba] zapisałbym %s:\n' "$1"; sed 's/^/ | /'
else
cat > "$1"
fi
}
# usluga AKCJA NAZWA — systemctl, ale z ostrzeżeniem, gdy nie ma systemd (np. kontener)
usluga() {
if [[ "$(cat /proc/1/comm)" == systemd ]]; then
zrob systemctl "$@"
else
log "UWAGA: brak systemd jako PID 1, pomijam: systemctl $*"
fi
}
while getopts ':u:k:nsh' opt; do
case $opt in
u) ADMIN=$OPTARG ;;
k) KLUCZ=$OPTARG ;;
n) PROBA=1 ;;
s) BEZ_AKTUALIZACJI=1 ;;
h) pomoc; exit 0 ;;
:) blad "opcja -$OPTARG wymaga wartości" ;;
*) pomoc >&2; exit 1 ;;
esac
done
# ---------- 0. Kontrole przed startem ----------
[[ $EUID -eq 0 ]] || blad "uruchom przez sudo albo jako root"
[[ -n $ADMIN && -n $KLUCZ ]] || { pomoc >&2; exit 1; }
[[ $ADMIN != root ]] || blad "administratorem ma być zwykłe konto, nie root"
[[ $ADMIN =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || blad "niepoprawna nazwa konta: $ADMIN"
ssh-keygen -l -f "$KLUCZ" >/dev/null 2>&1 || blad "$KLUCZ nie wygląda na klucz publiczny SSH"
# shellcheck source=/dev/null
. /etc/os-release
case " ${ID} ${ID_LIKE:-} " in
*" debian "*|*" ubuntu "*) RODZINA=debian; GRUPA_ADMIN=sudo; SSH_USLUGA=ssh ;;
*" rhel "*|*" fedora "*|*" centos "*) RODZINA=rhel; GRUPA_ADMIN=wheel; SSH_USLUGA=sshd ;;
*) blad "nie znam dystrybucji: $PRETTY_NAME" ;;
esac
log "System: $PRETTY_NAME (rodzina: $RODZINA)"
(( PROBA )) && log "TRYB PRÓBY: nic nie zostanie zmienione"
# ---------- 1. Aktualizacje i pakiety ----------
log "Krok 1/6: aktualizacje i pakiety"
if [[ $RODZINA == debian ]]; then
export DEBIAN_FRONTEND=noninteractive
zrob apt-get update -q
(( BEZ_AKTUALIZACJI )) || zrob apt-get -y -q upgrade
zrob apt-get -y -q install openssh-server ufw fail2ban unattended-upgrades
else
(( BEZ_AKTUALIZACJI )) || zrob dnf -y upgrade
# fail2ban jest w EPEL; na Rocky i Almie epel-release leży w repo extras
rpm -q epel-release >/dev/null 2>&1 || zrob dnf -y install epel-release
zrob dnf -y install openssh-server firewalld fail2ban fail2ban-firewalld dnf-automatic
fi
# ---------- 2. Konto administratora ----------
log "Krok 2/6: konto $ADMIN w grupie $GRUPA_ADMIN"
if id "$ADMIN" >/dev/null 2>&1; then
zrob usermod -aG "$GRUPA_ADMIN" "$ADMIN"
else
zrob useradd -m -s /bin/bash -G "$GRUPA_ADMIN" "$ADMIN"
fi
# Hasło będzie potrzebne do sudo. Ustawiamy je, tylko gdy go jeszcze nie ma.
if [[ $(passwd -S "$ADMIN" 2>/dev/null | awk '{print $2}') != P ]]; then
if [[ -t 0 ]]; then
log "Ustaw hasło dla $ADMIN (do sudo, nie do logowania):"
zrob passwd "$ADMIN"
else
log "UWAGA: $ADMIN nie ma hasła; ustaw je później: passwd $ADMIN"
fi
fi
# ---------- 3. Klucz SSH ----------
log "Krok 3/6: klucz SSH dla $ADMIN"
DOM=$(getent passwd "$ADMIN" | cut -d: -f6 || true)
DOM=${DOM:-/home/$ADMIN}
zrob install -d -m 700 -o "$ADMIN" -g "$ADMIN" "$DOM/.ssh"
if [[ -f $DOM/.ssh/authorized_keys ]] && grep -qxF -f "$KLUCZ" "$DOM/.ssh/authorized_keys"; then
log "klucz już jest w authorized_keys"
else
if (( PROBA )); then
printf ' [próba] dopisałbym klucz do %s\n' "$DOM/.ssh/authorized_keys"
else
cat "$KLUCZ" >> "$DOM/.ssh/authorized_keys"
fi
fi
zrob chown "$ADMIN:$ADMIN" "$DOM/.ssh/authorized_keys"
zrob chmod 600 "$DOM/.ssh/authorized_keys"
# SELinux (RHEL): nadaj plikom w .ssh właściwe etykiety
if command -v selinuxenabled >/dev/null && selinuxenabled; then
zrob restorecon -R "$DOM/.ssh"
fi
# ---------- 4. SSH tylko na klucze ----------
log "Krok 4/6: SSH tylko na klucze, bez roota"
zapisz "$DROPIN" <<'EOF'
# nowy-serwer.sh: logowanie wyłącznie kluczem, root nie wchodzi przez SSH
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
EOF
if (( ! PROBA )) && ! sshd -t; then
rm -f "$DROPIN"
blad "sshd -t odrzucił konfigurację; usunąłem $DROPIN, nic nie przeładowano"
fi
usluga try-reload-or-restart "$SSH_USLUGA"
# ---------- 5. Zapora ----------
log "Krok 5/6: zapora, wpuszczamy tylko SSH"
if [[ $RODZINA == debian ]]; then
zrob ufw default deny incoming
zrob ufw default allow outgoing
zrob ufw allow OpenSSH
zrob ufw --force enable
else
usluga enable --now firewalld
zrob firewall-cmd --permanent --add-service=ssh
zrob firewall-cmd --reload
fi
# ---------- 6. fail2ban i automatyczne poprawki ----------
log "Krok 6/6: fail2ban i automatyczne aktualizacje bezpieczeństwa"
zapisz /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
backend = systemd
EOF
(( PROBA )) || fail2ban-client -t >/dev/null || blad "fail2ban-client -t: błąd w jail.local"
usluga enable fail2ban
usluga restart fail2ban
if [[ $RODZINA == debian ]]; then
zapisz /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
else
zrob sed -i 's/^upgrade_type.*/upgrade_type = security/; s/^apply_updates.*/apply_updates = yes/' \
/etc/dnf/automatic.conf
usluga enable --now dnf-automatic.timer
fi
log "GOTOWE. NIE zamykaj tej sesji. W drugim oknie sprawdź:"
log " ssh -i TWÓJ_KLUCZ $ADMIN@$(hostname -I 2>/dev/null | awk '{print $1}')"
log " sudo whoami (ma odpowiedzieć: root)"shellcheck nowy-serwer.sh && echo czystoczystoCo tu jest ciekawego
Tryb próby, czyli -n. Funkcja zrob to taki pilot z przyciskiem „na niby”. Normalnie wykonuje komendę. Z -n tylko mówi, co by zrobiła. Funkcja zapisz robi to samo z plikami: zamiast zapisać, pokazuje treść. Zawsze odpalaj nowy skrypt najpierw na niby. Chirurg też najpierw rysuje flamastrem, gdzie będzie ciąć.
sudo ./nowy-serwer.sh -u eryk -k id_ed25519.pub -n[22:34:02] System: Ubuntu 24.04.5 LTS (rodzina: debian)
[22:34:02] TRYB PRÓBY: nic nie zostanie zmienione
[22:34:02] Krok 1/6: aktualizacje i pakiety
[próba] apt-get update -q
[próba] apt-get -y -q upgrade
[próba] apt-get -y -q install openssh-server ufw fail2ban unattended-upgrades
[22:34:02] Krok 2/6: konto eryk w grupie sudo
[próba] useradd -m -s /bin/bash -G sudo eryk
...
[22:34:07] Krok 4/6: SSH tylko na klucze, bez roota
[próba] zapisałbym /etc/ssh/sshd_config.d/10-nowy-serwer.conf:
| # nowy-serwer.sh: logowanie wyłącznie kluczem, root nie wchodzi przez SSH
| PermitRootLogin no
| PasswordAuthentication no
...
[22:34:07] Krok 5/6: zapora, wpuszczamy tylko SSH
[próba] ufw default deny incoming
[próba] ufw default allow outgoing
[próba] ufw allow OpenSSH
[próba] ufw --force enableRozpoznanie dystrybucji. Plik /etc/os-release ma zmienne ID i ID_LIKE. Na Rocky to na przykład ID="rocky" i ID_LIKE="rhel centos fedora". Doklejamy spacje z obu stron i szukamy słowa w spacjach, żeby „rhel” nie złapało się przypadkiem w środku innego słowa. Skrypt nie musi znać każdej dystrybucji z nazwy, wystarczy, że wie, do której rodziny należy. Jak w szatni: nie pytasz, jak się kto nazywa, tylko z której jest klasy.
Hasło do sudo, nie do logowania. Logujesz się kluczem, ale sudo dalej pyta o hasło. I dobrze: jak ktoś ukradnie Ci laptopa z kluczem, nadal nie jest rootem. passwd -S mówi, czy konto ma hasło: litera P znaczy „ma”, L „zablokowane”, NP „brak”. Skrypt pyta o hasło tylko wtedy, gdy siedzisz przy klawiaturze (-t 0), a jak leci z automatu, zostawia ostrzeżenie zamiast zawisnąć na zawsze, czekając na odpowiedź, której nikt nie da.
Klucz dopisywany tylko raz. grep -qxF -f KLUCZ szuka w authorized_keys całej linijki identycznej z kluczem. Uruchomisz skrypt dziesięć razy, klucz będzie raz. Ta cecha ma swoją mądrą nazwę: idempotentność. Po ludzku: włącznik światła, a nie dzwonek. Wciśnięcie „włącz” na już włączonym świetle nic nie psuje. Zapamiętaj to słowo, za kwadrans wróci.
Plik drop-in zamiast grzebania w sshd_config. Nie edytujemy głównego pliku, tylko kładziemy własny w /etc/ssh/sshd_config.d/. Główny plik ma na samej górze linijkę Include /etc/ssh/sshd_config.d/*.conf, a pliki z folderu czyta w kolejności alfabetycznej. I teraz smaczek z man sshd_config: dla każdego ustawienia wygrywa pierwsza znaleziona wartość. Nie ostatnia, jak w większości programów. Dlatego nasz plik nazywa się 10-...: obrazy chmurowe często kładą tam własny 50-cloud-init.conf z PasswordAuthentication yes. Sprawdziłem:
echo 'PasswordAuthentication yes' | sudo tee /etc/ssh/sshd_config.d/50-cloud-init.conf
sudo sshd -T | grep ^passwordauthpasswordauthentication noNasze 10- było pierwsze, więc wygrało. Gdybyś nazwał plik 99-moje.conf, przegrałby z chmurą i hasła dalej by działały, a Ty byłbyś przekonany, że nie. sshd -T wypisuje konfigurację, którą serwer naprawdę stosuje, po złożeniu wszystkich plików. Ufaj jemu, nie swoim oczom.
sshd -t przed przeładowaniem. Mała litera t to test składni. Jak test nie przejdzie, skrypt usuwa swój plik i kończy, zanim cokolwiek przeładuje. Bo sshd z zepsutą konfiguracją potrafi nie wstać, a wtedy jedyne drzwi do serwera są zamurowane.
try-reload-or-restart. [Ubuntu] Od Ubuntu 24.04 SSH budzi się przez gniazdo ssh.socket: usługa śpi, dopóki ktoś nie zapuka. try-reload-or-restart przeładuje ją, jeśli działa, a jeśli śpi, to nic nie robi, bo i tak obudzi się z nową konfiguracją. [RHEL] Usługa nazywa się sshd, nie ssh. Klasyczna pułapka przy przenoszeniu skryptów.
Funkcja usluga. Skrypt może kiedyś trafić tam, gdzie nie ma systemd jako procesu numer 1, czyli nie ma kierownika zmiany: do kontenera Dockera albo do chroota z płyty ratunkowej. Tam systemctl nie ma z kim rozmawiać. Funkcja sprawdza, kto jest PID 1 (/proc/1/comm), i w takim miejscu zamiast się wywalić, wypisuje uczciwe ostrzeżenie. Na maszynie wirtualnej i na serwerze ta gałąź się nie odpali.
Pokaz: uruchomienie naprawdę
sudo ./nowy-serwer.sh -u eryk -k id_ed25519.pub -s[22:34:30] System: Ubuntu 24.04.5 LTS (rodzina: debian)
[22:34:30] Krok 1/6: aktualizacje i pakiety
[22:34:30] Krok 2/6: konto eryk w grupie sudo
[22:34:30] Krok 3/6: klucz SSH dla eryk
[22:34:30] Krok 4/6: SSH tylko na klucze, bez roota
[22:34:30] Krok 5/6: zapora, wpuszczamy tylko SSH
Default incoming policy changed to 'deny'
Default outgoing policy changed to 'allow'
Rule added
Firewall is active and enabled on system startup
[22:34:33] Krok 6/6: fail2ban i automatyczne aktualizacje bezpieczeństwa
[22:34:33] GOTOWE. NIE zamykaj tej sesji. W drugim oknie sprawdź:Teraz w drugim oknie test trzech drzwi. Kluczem jako nowy admin, hasłem jako nowy admin i kluczem jako root:
ssh -i ~/.ssh/id_ed25519 eryk@ADRES 'whoami'
ssh -o PubkeyAuthentication=no eryk@ADRES true
ssh -i ~/.ssh/id_ed25519 root@ADRES trueeryk
eryk@ADRES: Permission denied (publickey).
root@ADRES: Permission denied (publickey).Pierwsze drzwi otwarte, dwa pozostałe zamknięte. W komunikacie w nawiasie serwer mówi, jakie metody w ogóle przyjmuje: tylko publickey. Hasło nawet nie jest na liście. Ochroniarz nie mówi „złe hasło”, tylko „tu się wchodzi na kartę, proszę pana”.
I zapora:
sudo ufw status verboseStatus: active
Default: deny (incoming), allow (outgoing), deny (routed)
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere[RHEL] To samo sprawdzasz przez sudo firewall-cmd --list-all, gdzie w linijce services: ma być ssh. A stan fail2ban na obu rodzinach: sudo fail2ban-client status sshd.
I drugie uruchomienie skryptu, żeby sprawdzić tę idempotentność:
[22:34:50] Krok 3/6: klucz SSH dla eryk
[22:34:50] klucz już jest w authorized_keysPlik authorized_keys dalej ma jedną linijkę. Włącznik, nie dzwonek.
Ta sama lista jako playbook Ansible
Skrypt działa. Ale popatrz, ile w nim jest kodu, który nie robi nic pożytecznego, tylko pilnuje: „czy użytkownik już jest”, „czy klucz już jest”, „a może tryb próby”. Połowa skryptu to sprawdzanie, czy czegoś przypadkiem nie trzeba nie robić.
Pamiętasz z lekcji 103: Ansible to lista zadań opisująca stan docelowy. Nie mówisz „dodaj użytkownika”, tylko „użytkownik ma istnieć”. Jak lista dla ekipy remontowej: „ściany mają być białe”. Jeśli już są białe, ekipa nie maluje drugi raz, tylko odhacza. Idempotentność dostajesz w prezencie, a tryb próby to po prostu --check.
Struktura folderu:
nowy-serwer-ansible/
├── ansible.cfg ([defaults] inventory = inventory.ini)
├── inventory.ini
├── klucze/eryk.pub
├── group_vars/nowe/vault.yml (zaszyfrowany ansible-vault)
└── nowy-serwer.yml# inventory.ini
[nowe]
web1 ansible_host=192.168.56.11
db1 ansible_host=192.168.56.12Hasło administratora nie leży otwartym tekstem, tylko jako skrót, w pliku zaszyfrowanym przez ansible-vault:
mkdir -p group_vars/nowe
printf 'admin_haslo_hash: "%s"\n' "$(openssl passwd -6)" > group_vars/nowe/vault.yml
ansible-vault encrypt group_vars/nowe/vault.yml
head -1 group_vars/nowe/vault.yml$ANSIBLE_VAULT;1.1;AES256openssl passwd -6 zapyta o hasło i zwróci jego skrót SHA-512 w formacie, który rozumie /etc/shadow. Do repozytorium Git trafia tylko zaszyfrowany plik.
I cały playbook:
---
# nowy-serwer.yml — to samo co nowy-serwer.sh, ale jako opis stanu docelowego
- name: Nowy serwer - stan docelowy
hosts: nowe
become: true
vars:
admin_nazwa: eryk
admin_klucz: "{{ lookup('ansible.builtin.file', 'klucze/eryk.pub') }}"
grupa_admin: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}"
ssh_usluga: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}"
# admin_haslo_hash leży zaszyfrowany w group_vars/nowe/vault.yml
ma_systemd: "{{ ansible_service_mgr == 'systemd' }}"
tasks:
# ---------- 1. Aktualizacje i pakiety ----------
- name: Aktualizacje i pakiety (Debian/Ubuntu)
when: ansible_os_family == 'Debian'
tags: pakiety
block:
- name: Aktualizacja systemu
ansible.builtin.apt:
update_cache: true
upgrade: safe
- name: Pakiety bazowe
ansible.builtin.apt:
name: [openssh-server, ufw, fail2ban, unattended-upgrades]
state: present
- name: Aktualizacje i pakiety (RHEL/Rocky/Alma)
when: ansible_os_family == 'RedHat'
tags: pakiety
block:
- name: Repozytorium EPEL
ansible.builtin.dnf:
name: epel-release
state: present
- name: Aktualizacja systemu
ansible.builtin.dnf:
name: "*"
state: latest # noqa package-latest
- name: Pakiety bazowe
ansible.builtin.dnf:
name: [openssh-server, firewalld, fail2ban, fail2ban-firewalld, dnf-automatic]
state: present
# ---------- 2. Konto administratora ----------
- name: Konto administratora
ansible.builtin.user:
name: "{{ admin_nazwa }}"
groups: "{{ grupa_admin }}"
append: true
shell: /bin/bash
password: "{{ admin_haslo_hash }}"
update_password: on_create
# ---------- 3. Klucz SSH ----------
- name: Klucz publiczny w authorized_keys
ansible.posix.authorized_key:
user: "{{ admin_nazwa }}"
key: "{{ admin_klucz }}"
state: present
# ---------- 4. SSH tylko na klucze ----------
- name: SSH bez haseł i bez roota
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/10-nowy-serwer.conf
owner: root
group: root
mode: "0644"
content: |
# Ansible: logowanie wyłącznie kluczem, root nie wchodzi przez SSH
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
validate: /usr/sbin/sshd -t -f %s
notify: Przeładuj SSH
# ---------- 5. Zapora ----------
- name: Zapora ufw (Debian/Ubuntu)
when: ansible_os_family == 'Debian'
tags: zapora
block:
- name: Wpuść SSH
community.general.ufw:
rule: allow
name: OpenSSH
- name: Włącz ufw, domyślnie odrzucaj ruch przychodzący
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Zapora firewalld (RHEL/Rocky/Alma)
when: ansible_os_family == 'RedHat'
tags: zapora
block:
- name: Włącz firewalld
ansible.builtin.service:
name: firewalld
state: started
enabled: true
when: ma_systemd
- name: Wpuść SSH na stałe i od razu
ansible.posix.firewalld:
service: ssh
permanent: true
immediate: true
state: enabled
# ---------- 6. fail2ban i automatyczne poprawki ----------
- name: Konfiguracja fail2ban
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
mode: "0644"
content: |
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
backend = systemd
notify: Restartuj fail2ban
- name: Automatyczne poprawki (Debian/Ubuntu)
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
when: ansible_os_family == 'Debian'
- name: Automatyczne poprawki bezpieczeństwa (RHEL)
ansible.builtin.lineinfile:
path: /etc/dnf/automatic.conf
regexp: "^{{ item.klucz }}"
line: "{{ item.klucz }} = {{ item.wartosc }}"
loop:
- { klucz: upgrade_type, wartosc: security }
- { klucz: apply_updates, wartosc: "yes" }
when: ansible_os_family == 'RedHat'
- name: Usługi włączone przy starcie
ansible.builtin.service:
name: "{{ item }}"
state: started
enabled: true
loop: "{{ ['fail2ban'] + (['dnf-automatic.timer'] if ansible_os_family == 'RedHat' else []) }}"
when: ma_systemd
handlers:
- name: Przeładuj SSH
ansible.builtin.service:
name: "{{ ssh_usluga }}"
state: reloaded
when: ma_systemd
- name: Restartuj fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted
when: ma_systemdNa co warto popatrzeć.
validate: /usr/sbin/sshd -t -f %s to ten sam bezpiecznik co sshd -t w skrypcie, tylko lepszy. Ansible zapisuje nowy plik obok, każe sshd go sprawdzić i dopiero gdy test przejdzie, podmienia oryginał. Zepsuty plik w ogóle nie trafia na miejsce.
Handlery, czyli notify: Przeładuj SSH. SSH zostanie przeładowany tylko wtedy, gdy plik się naprawdę zmienił, i tylko raz, na końcu, nawet jak powiadomi go pięć zadań. Jak kelner, który nie biega do kuchni z każdym talerzem osobno, tylko zbiera zamówienia z całego stolika.
ma_systemd to ta sama ostrożność co funkcja usluga w skrypcie: gdyby playbook trafił na maszynę bez systemd, na przykład do kontenera, zadania z usługami zostaną pominięte. Na maszynie wirtualnej ten warunek jest prawdziwy i niczego nie zmienia.
Moduły ansible.posix.authorized_key, ansible.posix.firewalld i community.general.ufw nie są w gołym ansible-core. Są w pełnym pakiecie ansible, a jak masz tylko rdzeń, doinstalujesz je jedną komendą:
ansible-galaxy collection install ansible.posix community.generalPokaz: playbook
Najpierw składnia, potem na niby z różnicami, potem naprawdę:
ansible-playbook nowy-serwer.yml --syntax-check
ansible-playbook nowy-serwer.yml --ask-vault-pass --check --diff
ansible-playbook nowy-serwer.yml --ask-vault-passplaybook: nowy-serwer.yml
...
TASK [Konto administratora] ****************************************************
changed: [web1]
TASK [Klucz publiczny w authorized_keys] ***************************************
changed: [web1]
TASK [SSH bez haseł i bez roota] ***********************************************
changed: [web1]
TASK [Włącz ufw, domyślnie odrzucaj ruch przychodzący] *************************
changed: [web1]
TASK [Włącz firewalld] *********************************************************
skipping: [web1]
...
PLAY RECAP *********************************************************************
web1 : ok=13 changed=11 unreachable=0 failed=0 skipped=6I najpiękniejsza linijka w całej lekcji. Drugie uruchomienie:
PLAY RECAP *********************************************************************
web1 : ok=11 changed=0 unreachable=0 failed=0 skipped=6changed=0. Ekipa remontowa przyszła, obejrzała ściany, wszystko białe, poszła do domu. Zero zmian to nie nuda. Zero zmian to dowód, że serwer jest dokładnie w opisanym stanie. Możesz to puszczać co noc z crona i rano sprawdzać, czy ktoś przypadkiem czegoś nie „poprawił” ręcznie. To się nazywa wykrywanie dryfu konfiguracji.
[RHEL] Ten sam playbook na Rocky przeskoczy zadania z ufw, a wykona te z firewalld, EPEL i dnf-automatic. Jedna lista, dwie rodziny. Dlatego when: stoi przy blokach, a nie w każdym zadaniu osobno.
Ściąga: skrypt kontra playbook
| Cecha | Skrypt Bash | Playbook Ansible |
|---|---|---|
| Na ilu serwerach | jeden, ten, na którym go odpalisz | tyle, ile w inwentarzu, naraz |
| Czego potrzebuje na serwerze | Basha | SSH i Pythona |
| Tryb próby | piszesz sam (zrob, zapisz) | --check --diff w prezencie |
| Idempotentność | piszesz sam (grep -qxF, id) | w modułach |
| Bezpiecznik konfiguracji | sshd -t i ręczne sprzątanie | validate: |
| Przeładowanie usług | zawsze | tylko po zmianie (handlery) |
| Kiedy wygrywa | pierwszy serwer, brak Pythona, cloud-init | od drugiego serwera w górę |
Prawda, której nikt nie mówi na kursach Ansible: w prawdziwym życiu masz oba. Krótki skrypt w polu „user data” u dostawcy chmury, czyli w cloud-init, robi minimum, żeby Ansible mógł wejść: konto i klucz. Resztę robi playbook. Skrypt to klucz do drzwi wejściowych, playbook to ekipa, która urządza mieszkanie.
Jak to działa pod spodem
fail2ban to ochroniarz, który czyta dziennik wejść. Z naszym backend = systemd czyta dziennik systemd, a nie plik tekstowy. Szuka linijek pasujących do wzorców z /etc/fail2ban/filter.d/sshd.conf, na przykład nieudanych logowań z danego adresu. Jak z jednego adresu przyjdzie maxretry = 5 porażek w ciągu findtime = 10m, dopisuje ten adres do zapory na bantime = 1h. [Ubuntu] W 24.04 pakiet sam kładzie /etc/fail2ban/jail.d/defaults-debian.conf z backend = systemd i banaction = nftables, czyli bany lądują w nftables. [RHEL] Pakiet fail2ban-firewalld ustawia akcję tak, żeby bany szły przez firewalld, a nie obok niego. Dwóch ochroniarzy na jednych drzwiach, którzy się nie dogadują, to przepis na awarię.
Dlaczego jail.local, a nie jail.conf? Bo jail.conf należy do pakietu i aktualizacja może go nadpisać. Pliki .local są czytane po .conf i nadpisują jego ustawienia, a pakiet ich nigdy nie dotyka. Ta sama zasada co drop-in w SSH: nie piszesz po cudzych kartkach, tylko przyklejasz swoją karteczkę. Test konfiguracji bez uruchamiania:
sudo fail2ban-client -tOK: configuration test is successfulAnsible pod spodem robi coś, co sam robiłeś w lekcji 102: łączy się po SSH, kopiuje na serwer mały program w Pythonie (moduł), uruchamia go z parametrami, odbiera wynik w JSON-ie i sprząta. Moduł user sprawdza /etc/passwd, porównuje ze stanem docelowym i zmienia tylko różnice. Stąd changed albo ok. Dlatego Ansible potrzebuje na serwerze Pythona, a na Twoim komputerze nic poza SSH i samym Ansiblem. Żadnego agenta, żadnej usługi w tle.
⚠ Typowy błąd
Pierwszy: puszczasz playbook na świeży serwer w trybie --check i dostajesz:
TASK [Klucz publiczny w authorized_keys] ***************************************
fatal: [web1]: FAILED! => {"changed": false, "msg": "Either user must exist or you must provide full path to key file in check mode"}To nie błąd w playbooku. W trybie próby zadanie „utwórz użytkownika” tylko udaje, że utworzyło konto, więc w następnym zadaniu konta dalej nie ma i moduł nie wie, gdzie jest jego folder domowy. Tryb próby nie przewidzi skutków zadań, które się naprawdę nie wykonały. Na świeżym serwerze uruchom playbook naprawdę na maszynie wirtualnej. Na serwerze, gdzie konto już jest, --check przejdzie czysto.
Drugi, groźniejszy: literówka w pliku SSH.
echo 'PermitRootLogn no' | sudo tee /etc/ssh/sshd_config.d/99-test.conf
sudo sshd -t/etc/ssh/sshd_config.d/99-test.conf: line 1: Bad configuration option: PermitRootLogn
/etc/ssh/sshd_config.d/99-test.conf: terminating, 1 bad configuration optionsJeden brakujący znak i sshd przy następnym restarcie nie wstanie. Właśnie dlatego skrypt robi sshd -t przed przeładowaniem, a playbook ma validate:. I właśnie dlatego masz otwartą drugą sesję: działające połączenie SSH żyje dalej nawet wtedy, gdy nowy sshd się nie uruchomi. To Twoje koło ratunkowe. Naprawiasz plik, sshd -t, przeładowanie, test w trzecim oknie.
✎ Zadanie
Dopisz do playbooka dwa zadania: ustaw strefę czasową Europe/Warsaw i nazwę hosta równą nazwie z inwentarza (web1, db1). Uruchom playbook dwa razy i sprawdź, że za drugim razem jest changed=0. Podpowiedź: moduły community.general.timezone i ansible.builtin.hostname, a nazwa z inwentarza siedzi w zmiennej inventory_hostname.
Pokaż rozwiązanie
Zadanie było takie: dopisać do playbooka z projektu 1 strefę czasową i nazwę hosta z inwentarza, a potem udowodnić, że drugi przebieg niczego nie zmienia. Dwa zadania na końcu listy tasks:
# ---------- 7. Strefa czasowa i nazwa hosta ----------
- name: Strefa czasowa Europe/Warsaw
community.general.timezone:
name: Europe/Warsaw
- name: Nazwa hosta jak w inwentarzu
ansible.builtin.hostname:
name: "{{ inventory_hostname }}"inventory_hostname to nazwa, pod którą maszyna stoi w inwentarzu, czyli web1 albo db1, a nie jej adres IP. Pierwsze uruchomienie, potem drugie:
ansible-playbook nowy-serwer.yml --ask-vault-pass
ansible-playbook nowy-serwer.yml --ask-vault-passTASK [Strefa czasowa Europe/Warsaw] ********************************************
changed: [web1]
TASK [Nazwa hosta jak w inwentarzu] ********************************************
changed: [web1]
...
web1 : ok=3 changed=2 unreachable=0 failed=0 ...
(drugi raz)
web1 : ok=3 changed=0 unreachable=0 failed=0 ...Jedna uwaga. Moduł hostname na Ubuntu i RHEL domyślnie woła hostnamectl, a ten rozmawia z systemd. Na maszynie bez działającego systemd, na przykład w kontenerze, dostaniesz uczciwe:
fatal: [web1]: FAILED! => {"changed": false, "msg": "Command failed rc=1, out=, err=Failed to connect to bus: Connection refused\n"}Na maszynie wirtualnej tego nie zobaczysz. W kontenerze pomaga parametr use:, który każe modułowi pisać prosto do /etc/hostname zamiast pytać systemd. Ja dodałem go przez zmienną z inwentarza, żeby na VM-kach dalej działała autodetekcja:
ansible.builtin.hostname:
name: "{{ inventory_hostname }}"
use: "{{ hostname_strategia | default(omit) }}"default(omit) znaczy: jak zmiennej nie ma, udawaj, że tej linijki w ogóle nie napisałem. Na VM jej nie ustawiasz, a na maszynie bez systemd piszesz w inwentarzu hostname_strategia=alpine. Wynik: pierwszy raz changed=2, drugi raz changed=0, w /etc/timezone siedzi Europe/Warsaw, w /etc/hostname siedzi web1. Ekipa remontowa przyszła drugi raz, wszystko na miejscu, poszła na kawę.
Co dalej
Mamy bezpieczny, pusty serwer. Pusty serwer jest bezpieczny, ale nudny. W następnym projekcie stawiamy na nim recepcję hotelu: Apache z dwiema stronami, obie na HTTPS, z własną firmową pieczątką do certyfikatów, przekierowaniem z HTTP i porządnymi testami na koniec.