Projekt 2: dwie strony na Apache z HTTPS
Po tej lekcji: kursant jednym skryptem stawia na jednym Apache dwie (albo dziesięć) stron, każdą z własnym certyfikatem i przekierowaniem z HTTP na HTTPS, a drugim skryptem automatycznie sprawdza, że wszystko działa, na Ubuntu i na RHEL.
Wstęp
Poniedziałek, dziewiąta rano. Dzwoni szef: „Klient mówi, że sklep straszy jakimś czerwonym ostrzeżeniem”. Wchodzisz. Certyfikat wygasł w sobotę o trzeciej w nocy. Ktoś kiedyś wygenerował go ręcznie, wkleił do konfiguracji i od tamtej pory nikt nie pamiętał, że on w ogóle istnieje. Jak gaśnica w biurze: wisi, nikt nie patrzy na datę, aż do pożaru. Dziś stawiamy serwer WWW tak, żeby ten telefon się nie zdarzył. Jeden Apache, dwie strony: sklep.test i blog.test. Każda na HTTPS, każda z własnym certyfikatem, wejście po zwykłym HTTP grzecznie przekierowane na HTTPS. Wszystko jednym skryptem, który można odpalić drugi raz bez szkody. A na koniec drugi skrypt, który sprawdza strony tak, jak sprawdziłby je klient, i krzyczy, zanim certyfikat wygaśnie. W lekcji 90 robiliśmy to ręcznie. Dziś robimy to tak, żeby ręce mogły zostać w kieszeni.
Jeden hotel, dwa szyldy
Pamiętasz: serwer WWW to recepcja hotelu. Dziś mamy jeden budynek, jedną recepcję, ale dwa szyldy nad wejściem: „Sklep” i „Blog”. Gość podchodzi i mówi, do którego hotelu przyszedł. Recepcjonistka wyciąga właściwy klucz. W Apache gość mówi to nagłówkiem Host: w żądaniu HTTP, a każdy szyld to jeden VirtualHost. To się nazywa hosting po nazwie: jeden adres IP, wiele stron. Z HTTPS jest jeden haczyk. Zanim zaczniemy rozmowę po szyfrowanemu, serwer musi pokazać dowód tożsamości, czyli certyfikat. A skąd ma wiedzieć, który dowód pokazać, skoro gość jeszcze nic nie powiedział? Dlatego klient na samym początku powitania TLS mówi jawnie, do kogo przyszedł. To się nazywa SNI, Server Name Indication. Gość mówi przy drzwiach „ja do Sklepu”, a recepcjonistka pokazuje legitymację Sklepu, nie Bloga. [dla zaawansowanych: SNI leci otwartym tekstem, więc ktoś podsłuchujący sieć widzi nazwę strony, choć nie widzi treści. Rozwiązuje to ECH, Encrypted Client Hello, ale to temat na zupełnie inna lekcja.]
Skąd wziąć certyfikat: notariusz publiczny albo firmowy
Certyfikat to legitymacja z pieczątką. Ważne jest nie to, co na niej napisano, tylko kto ją podstemplował. Pieczątki ufamy, jeśli znamy urząd, który ją przybił. Urząd to CA, urząd certyfikacji. Masz dwie drogi. Droga pierwsza: publiczny notariusz, czyli Let's Encrypt i narzędzie certbot. Darmowe, automatyczne, przeglądarki na całym świecie mu ufają. Warunek: domena musi naprawdę istnieć i wskazywać na Twój serwer, bo Let's Encrypt sprawdza, czy ta domena należy do Ciebie. Droga druga: własny, firmowy urząd. Robisz pieczątkę raz, a potem podstemplowujesz nią certyfikaty dla stron wewnętrznych: intranet, panel, laboratorium. Ufają mu tylko komputery, na których tę pieczątkę zainstalujesz. W firmie robi to dział IT jednym ruchem na wszystkich laptopach. Nasz skrypt umie obie drogi. Domyślnie robi własny urząd, bo w laboratorium nie masz prawdziwej domeny. Z opcją -l EMAIL idzie do Let's Encrypt. I uwaga na końcówkę .test: ta końcówka jest zarezerwowana do testów, nigdy nie będzie prawdziwą domeną, więc nie ukradniesz nikomu ruchu.
Plan
- Pakiety: [Ubuntu]
apache2, modułyssl,headers,rewriteprzeza2enmod. [RHEL]httpdimod_ssl. - Firmowy urząd certyfikacji, tylko raz, i dodanie go do zaufanych w systemie.
- Dla każdej strony: folder, certyfikat, plik VirtualHosta. [Ubuntu]
/etc/apache2/sites-availableplusa2ensite. [RHEL]/etc/httpd/conf.d, bez żadnego włączania. - Zapora: porty 80 i 443.
apachectl configtest, a dopiero potem przeładowanie. Z projektu 1 bierzemy te same nawyki: wykrywanie rodziny dystrybucji z/etc/os-release, funkcjelogiblad, sprawdzenie, czy jest systemd. Skrypty to też klocki.
Skrypt: pełny kod
Plik dwie-strony.sh. Nazwa skromna, bo przyjmuje dowolnie dużo domen.
#!/usr/bin/env bash
# dwie-strony.sh — kilka stron na jednym Apache, każda na HTTPS
# Tryb domyślny: własny urząd certyfikacji (laboratorium, sieć firmowa).
# Tryb -l EMAIL: prawdziwe domeny i certyfikaty Let's Encrypt (certbot).
set -euo pipefail
CA_DIR=/etc/ssl/firmowe-ca
LE_EMAIL=""
DO_HOSTS=0
pomoc() {
cat <<EOF
Użycie: sudo $0 [-l EMAIL] [-H] DOMENA [DOMENA...]
-l EMAIL certyfikaty Let's Encrypt (domeny muszą wskazywać na ten serwer)
-H dopisz domeny do /etc/hosts jako 127.0.0.1 (laboratorium)
-h ta pomoc
Przykład: sudo $0 -H sklep.test blog.test
EOF
}
log() { printf '[%s] %s\n' "$(date +%T)" "$*"; }
blad() { printf 'BŁĄD: %s\n' "$*" >&2; exit 1; }
usluga() {
if [[ "$(cat /proc/1/comm)" == systemd ]]; then
systemctl "$@"
else
log "UWAGA: brak systemd jako PID 1, pomijam: systemctl $*"
fi
}
while getopts ':l:Hh' opt; do
case $opt in
l) LE_EMAIL=$OPTARG ;;
H) DO_HOSTS=1 ;;
h) pomoc; exit 0 ;;
:) blad "opcja -$OPTARG wymaga wartości" ;;
*) pomoc >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1))
[[ $EUID -eq 0 ]] || blad "uruchom przez sudo"
(( $# >= 1 )) || { pomoc >&2; exit 1; }
for d in "$@"; do
[[ $d =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] \
|| blad "to nie wygląda na domenę: $d"
done
# ---------- Rodzina dystrybucji: nazwy i ścieżki ----------
# shellcheck source=/dev/null
. /etc/os-release
case " ${ID} ${ID_LIKE:-} " in
*" debian "*|*" ubuntu "*)
RODZINA=debian; USLUGA=apache2; LOGI=/var/log/apache2
KONF_DIR=/etc/apache2/sites-available; CERTY=/etc/ssl/strony ;;
*" rhel "*|*" fedora "*|*" centos "*)
RODZINA=rhel; USLUGA=httpd; LOGI=/var/log/httpd
KONF_DIR=/etc/httpd/conf.d; CERTY=/etc/pki/tls/strony ;;
*) blad "nie znam dystrybucji: $PRETTY_NAME" ;;
esac
# ---------- 1. Pakiety i moduły ----------
log "Krok 1/5: Apache i moduły"
if [[ $RODZINA == debian ]]; then
export DEBIAN_FRONTEND=noninteractive
apt-get -y -q install apache2 openssl ${LE_EMAIL:+certbot python3-certbot-apache}
a2enmod -q ssl headers rewrite
a2dissite -q 000-default || true
else
dnf -y -q install httpd mod_ssl openssl ${LE_EMAIL:+certbot python3-certbot-apache}
fi
# ---------- 2. Własny urząd certyfikacji (tylko tryb lokalny) ----------
if [[ -z $LE_EMAIL && ! -f $CA_DIR/ca.crt ]]; then
log "Krok 2/5: zakładam firmowy urząd certyfikacji w $CA_DIR"
install -d -m 700 "$CA_DIR"
openssl req -x509 -new -newkey rsa:4096 -nodes -sha256 -days 3650 \
-keyout "$CA_DIR/ca.key" -out "$CA_DIR/ca.crt" \
-subj "/O=Moja Firma/CN=Moja Firma - lokalny CA" 2>/dev/null
chmod 600 "$CA_DIR/ca.key"
if [[ $RODZINA == debian ]]; then
cp "$CA_DIR/ca.crt" /usr/local/share/ca-certificates/firmowe-ca.crt
update-ca-certificates >/dev/null
else
cp "$CA_DIR/ca.crt" /etc/pki/ca-trust/source/anchors/firmowe-ca.crt
update-ca-trust
fi
else
log "Krok 2/5: urząd certyfikacji już jest albo używamy Let's Encrypt"
fi
# certyfikat_dla DOMENA — klucz, wniosek (CSR) i certyfikat podpisany przez nasz CA
certyfikat_dla() {
local d=$1 kat=$CERTY/$1
install -d -m 700 "$kat"
[[ -f $kat/cert.pem ]] && openssl x509 -checkend $((30*86400)) -noout -in "$kat/cert.pem" \
&& { log " $d: certyfikat ważny jeszcze ponad 30 dni, zostawiam"; return 0; }
openssl req -new -newkey rsa:2048 -nodes -keyout "$kat/klucz.pem" -out "$kat/wniosek.csr" \
-subj "/CN=$d" 2>/dev/null
openssl x509 -req -in "$kat/wniosek.csr" -CA "$CA_DIR/ca.crt" -CAkey "$CA_DIR/ca.key" \
-CAcreateserial -days 365 -sha256 -out "$kat/cert.pem" \
-extfile <(printf 'subjectAltName=DNS:%s,DNS:www.%s\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\n' "$d" "$d") \
2>/dev/null
chmod 600 "$kat/klucz.pem"
log " $d: nowy certyfikat do $(openssl x509 -enddate -noout -in "$kat/cert.pem" | cut -d= -f2)"
}
# ---------- 3. Strony: katalogi, certyfikaty, VirtualHosty ----------
log "Krok 3/5: strony"
for d in "$@"; do
kat=/var/www/$d/public
install -d -m 755 "$kat"
[[ -f $kat/index.html ]] || printf '<!doctype html>\n<title>%s</title>\n<h1>To jest %s</h1>\n' "$d" "$d" > "$kat/index.html"
(( DO_HOSTS )) && ! grep -qw "$d" /etc/hosts && printf '127.0.0.1 %s www.%s\n' "$d" "$d" >> /etc/hosts
if [[ -n $LE_EMAIL ]]; then
# certbot sam dopisze VirtualHosta na 443 i przekierowanie
cat > "$KONF_DIR/$d.conf" <<EOF
<VirtualHost *:80>
ServerName $d
ServerAlias www.$d
DocumentRoot $kat
ErrorLog $LOGI/${d}_error.log
CustomLog $LOGI/${d}_access.log combined
</VirtualHost>
EOF
else
certyfikat_dla "$d"
cat > "$KONF_DIR/$d.conf" <<EOF
<VirtualHost *:80>
ServerName $d
ServerAlias www.$d
Redirect permanent / https://$d/
</VirtualHost>
<VirtualHost *:443>
ServerName $d
ServerAlias www.$d
DocumentRoot $kat
SSLEngine on
SSLCertificateFile $CERTY/$d/cert.pem
SSLCertificateKeyFile $CERTY/$d/klucz.pem
Header always set Strict-Transport-Security "max-age=300"
Header always set X-Content-Type-Options "nosniff"
<Directory $kat>
Require all granted
Options -Indexes
</Directory>
ErrorLog $LOGI/${d}_error.log
CustomLog $LOGI/${d}_access.log combined
</VirtualHost>
EOF
fi
[[ $RODZINA == debian ]] && a2ensite -q "$d"
log " $d: gotowe ($KONF_DIR/$d.conf)"
done
if [[ $RODZINA == rhel ]] && command -v restorecon >/dev/null; then
restorecon -R /var/www "$CERTY"
fi
# ---------- 4. Zapora ----------
log "Krok 4/5: zapora, porty 80 i 443"
if [[ $RODZINA == debian ]]; then
if ufw status | grep -q '^Status: active'; then ufw allow 'Apache Full'; fi
else
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reload
fi
# ---------- 5. Test konfiguracji i przeładowanie ----------
log "Krok 5/5: apachectl configtest i przeładowanie"
apachectl configtest || blad "konfiguracja Apache ma błąd, NIE przeładowuję"
usluga enable "$USLUGA"
usluga reload-or-restart "$USLUGA"
if [[ -n $LE_EMAIL ]]; then
for d in "$@"; do
certbot --apache --non-interactive --agree-tos -m "$LE_EMAIL" --redirect -d "$d" -d "www.$d"
done
fi
log "GOTOWE. Sprawdź: ./sprawdz-strony.sh $*"Co tu jest ciekawego
Pieczątka robiona raz. Krok 2 ma warunek: urząd zakładamy tylko wtedy, gdy nie ma pliku ca.crt. Gdyby skrypt przy każdym uruchomieniu robił nowy urząd, wszystkie wcześniejsze certyfikaty nagle byłyby podbite pieczątką, której nikt już nie zna. Klucz urzędu, ca.key, ma prawa 600 i jest najcenniejszym plikiem na tym serwerze. Kto go ma, ten może podrobić legitymację dowolnej strony dla wszystkich komputerów, które temu urzędowi ufają. [dla zaawansowanych: w prawdziwej firmie klucz głównego urzędu leży offline, a certyfikaty podpisuje urząd pośredni. W laboratorium upraszczamy do jednego poziomu.]
Zaufanie w systemie. Sam certyfikat urzędu to za mało, system musi go wpisać na listę zaufanych. [Ubuntu] plik ląduje w /usr/local/share/ca-certificates/ z końcówką .crt i update-ca-certificates. [RHEL] /etc/pki/ca-trust/source/anchors/ i update-ca-trust. Przeglądarki na Twoim laptopie mają często własną listę, więc tam dodajesz go osobno.
Funkcja certyfikat_dla. Najpierw pyta openssl x509 -checkend, czy certyfikat przeżyje jeszcze 30 dni. Jeśli tak, nic nie robi. Jeśli nie ma certyfikatu albo kończy się termin, robi nowy klucz, wniosek (CSR) i podbija go pieczątką. Czyli ten sam skrypt wpięty w crona raz w tygodniu jest od razu automatem do odnawiania. Gaśnica sama się wymienia przed datą.
SAN, a nie CN. Nazwy strony w certyfikacie szukaj w rozszerzeniu subjectAltName, czyli SAN. Przeglądarki od lat sprawdzają nazwę właśnie tam, a stare pole CN traktują jak napis dla ozdoby. Dlatego do każdego certyfikatu dopisujemy przez -extfile dwie nazwy: sklep.test i www.sklep.test. Zapis <(printf ...) to podstawianie procesu: Bash udaje, że wynik printf jest plikiem. Żadnego pliku tymczasowego do sprzątania.
Dwa VirtualHosty na stronę. Na porcie 80 tylko jedna linijka: Redirect permanent / https://$d/. Recepcjonista przy bocznym wejściu nikogo nie obsługuje, tylko pokazuje palcem drzwi frontowe. Kod 301 mówi przeglądarce: „przenieśliśmy się na stałe, zapamiętaj”.
HSTS z małą liczbą. Nagłówek Strict-Transport-Security każe przeglądarce przez podany czas w ogóle nie próbować zwykłego HTTP dla tej strony. Mamy max-age=300, czyli 5 minut. Celowo. Jak się pomylisz, przeglądarki zapomną po 5 minutach. Jak wszystko działa od tygodnia, podnosisz do roku, czyli 31536000. Zacząć od roku to jak wylać beton, zanim sprawdzisz, czy szalunek stoi prosto.
apachectl configtest przed przeładowaniem. Ta sama zasada co sshd -t w projekcie 1: najpierw sprawdź kartkę, potem ją przyklej. Jeśli test nie przejdzie, skrypt kończy się błędem, a stara konfiguracja dalej działa. Strony nie lecą w dół przez literówkę.
Różnice RHEL. Nie ma a2ensite: każdy plik *.conf w /etc/httpd/conf.d jest włączony, bo tak każe linijka IncludeOptional conf.d/*.conf w głównej konfiguracji. Moduł SSL to osobny pakiet mod_ssl, a headers i rewrite są włączone od razu. No i SELinux: restorecon -R nadaje plikom stron etykietę httpd_sys_content_t, a certyfikatom etykietę certyfikatów, żeby Apache w ogóle mógł je przeczytać. Pamiętasz z lekcji 82: pliki skopiowane mv z domowego folderu potrafią przynieść złą etykietę i wtedy jest 403 mimo poprawnych uprawnień.
Uruchomienie
Ostrzeżenie standardowe: skrypt wyłącza domyślną stronę Apache i zmienia zaporę. Odpalaj na maszynie wirtualnej albo na serwerze, na którym nic jeszcze nie stoi.
chmod +x dwie-strony.sh sprawdz-strony.sh
sudo ./dwie-strony.sh -H sklep.test blog.test[02:41:09] Krok 1/5: Apache i moduły
[02:41:09] Krok 2/5: zakładam firmowy urząd certyfikacji w /etc/ssl/firmowe-ca
[02:41:11] Krok 3/5: strony
[02:41:11] sklep.test: nowy certyfikat do Nov 4 01:41:11 2027 GMT
[02:41:11] sklep.test: gotowe (/etc/apache2/sites-available/sklep.test.conf)
[02:41:12] blog.test: nowy certyfikat do Nov 4 01:41:12 2027 GMT
[02:41:12] blog.test: gotowe (/etc/apache2/sites-available/blog.test.conf)
[02:41:12] Krok 4/5: zapora, porty 80 i 443
[02:41:12] Krok 5/5: apachectl configtest i przeładowanie
Syntax OK
[02:41:12] GOTOWE. Sprawdź: ./sprawdz-strony.sh sklep.test blog.testOpcja -H dopisuje nazwy do /etc/hosts, bo .test nie istnieje w żadnym DNS-ie. To nasza prywatna książka telefoniczna. [na ekranie: tail /etc/hosts z dwiema nowymi linijkami] A teraz moment prawdy. Apache, pokaż, co masz na recepcji:
sudo apachectl -S*:80 is a NameVirtualHost
default server blog.test (/etc/apache2/sites-enabled/blog.test.conf:1)
port 80 namevhost blog.test (/etc/apache2/sites-enabled/blog.test.conf:1)
alias www.blog.test
port 80 namevhost sklep.test (/etc/apache2/sites-enabled/sklep.test.conf:1)
alias www.sklep.test
*:443 is a NameVirtualHost
default server blog.test (/etc/apache2/sites-enabled/blog.test.conf:7)
port 443 namevhost blog.test (/etc/apache2/sites-enabled/blog.test.conf:7)
port 443 namevhost sklep.test (/etc/apache2/sites-enabled/sklep.test.conf:7)Dwa szyldy na porcie 80, dwa na 443. Zwróć uwagę na default server blog.test. Do tego wrócimy pod spodem.
Skrypt testujący: sprawdź jak klient
Ręczne klikanie w przeglądarce to nie test, to wycieczka. Test to coś, co da się odpalić z crona o trzeciej w nocy. Plik sprawdz-strony.sh dla każdej domeny sprawdza trzy rzeczy: czy HTTP przekierowuje dokładnie tam, gdzie trzeba, czy HTTPS odpowiada z poprawnym certyfikatem i ile dni zostało do końca jego ważności.
#!/usr/bin/env bash
# sprawdz-strony.sh — czy każda strona odpowiada, przekierowuje i ma ważny certyfikat
set -uo pipefail
(( $# >= 1 )) || { echo "Użycie: $0 DOMENA [DOMENA...]" >&2; exit 2; }
bledy=0
ok() { printf ' [ OK ] %s\n' "$*"; }
zle() { printf ' [BŁĄD] %s\n' "$*"; bledy=$((bledy + 1)); }
for d in "$@"; do
echo "== $d"
read -r kod dokad < <(curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://$d/")
if [[ $kod == 301 && $dokad == "https://$d/" ]]; then
ok "http -> $kod -> $dokad"
else
zle "http zwraca $kod ${dokad:-bez przekierowania}"
fi
# bez -k: curl sprawdza certyfikat tak jak przeglądarka
if tytul=$(curl -sS --fail "https://$d/" 2>&1); then
ok "https: $(grep -o '<title>.*</title>' <<<"$tytul")"
else
zle "https: $tytul"
fi
koniec=$(openssl s_client -connect "$d:443" -servername "$d" </dev/null 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -n $koniec ]]; then
dni=$(( ($(date -d "$koniec" +%s) - $(date +%s)) / 86400 ))
if (( dni >= 14 )); then ok "certyfikat ważny jeszcze $dni dni"; else zle "certyfikat wygasa za $dni dni"; fi
else
zle "nie udało się pobrać certyfikatu"
fi
done
echo "Błędów: $bledy"
exit $(( bledy > 0 ))Najważniejszy szczegół: curl bez -k. Opcja -k znaczy „nie sprawdzaj certyfikatu”, czyli dokładnie to, czego klient nie zrobi. Test z -k to jak sprawdzanie, czy drzwi są zamknięte, przez otwarte okno. Wynik:
./sprawdz-strony.sh sklep.test blog.test; echo "kod=$?"== sklep.test
[ OK ] http -> 301 -> https://sklep.test/
[ OK ] https: <title>sklep.test</title>
[ OK ] certyfikat ważny jeszcze 364 dni
== blog.test
[ OK ] http -> 301 -> https://blog.test/
[ OK ] https: <title>blog.test</title>
[ OK ] certyfikat ważny jeszcze 364 dni
Błędów: 0
kod=0Kod wyjścia 0 przy sukcesie, 1 przy błędach, 2 przy złym użyciu. Kciuk w górę albo w dół, który zrozumie cron, Ansible i projekt 4, w którym będziemy wysyłać maile. A drugie uruchomienie dwie-strony.sh? Spokój:
[02:41:13] sklep.test: certyfikat ważny jeszcze ponad 30 dni, zostawiam
[02:41:13] blog.test: certyfikat ważny jeszcze ponad 30 dni, zostawiamWersja na prawdziwy serwer: Let's Encrypt
Masz domenę, na przykład mojsklep.pl, i rekord A w DNS wskazuje na serwer. Port 80 musi być otwarty z internetu, bo tam Let's Encrypt zagląda po dowód, że serwer jest Twój. Wtedy:
sudo ./dwie-strony.sh -l admin@mojsklep.pl mojsklep.pl mojblog.plSkrypt stawia VirtualHosty na porcie 80, a resztę robi certbot --apache: pobiera certyfikat, dopisuje VirtualHosta na 443 i przekierowanie, bo dostał --redirect. Pakiety: [Ubuntu] certbot i python3-certbot-apache z repozytorium. [RHEL] te same nazwy, ale z EPEL, które włączaliśmy w projekcie 1. Odnawianie robi się samo. [Ubuntu] pakiet certbot przynosi timer certbot.timer, który odpala się dwa razy na dobę, i zapasowy plik w /etc/cron.d/certbot. [RHEL] sprawdź systemctl list-timers | grep certbot, czy timer odnawiania jest włączony, i jeśli nie, włącz go. I zawsze zrób próbę generalną:
sudo certbot renew --dry-runCzemu to takie ważne? Bo certyfikaty robią się coraz krótsze. Let's Encrypt wydaje dziś certyfikaty na 90 dni i zapowiedział, że domyślny profil zejdzie do 64 dni w lutym 2027 i do 45 dni w lutym 2028. A wszystkie publiczne certyfikaty, od 15 marca 2026, mogą żyć najwyżej 200 dni i ten limit ma dalej spadać, aż do 47 dni w 2029 roku. Era „wygeneruję ręcznie raz na rok” właśnie się kończy. Jeśli coś robisz ręcznie co 45 dni, to w końcu raz zapomnisz. Automat nie zapomina, najwyżej się psuje, i dlatego mamy skrypt testujący.
Jak to działa pod spodem
Co się dzieje, gdy wpisujesz https://sklep.test?
- DNS, a u nas
/etc/hosts, zamienia nazwę na adres. Oba szyldy mają ten sam adres 127.0.0.1. - Klient łączy się z portem 443 i zaczyna powitanie TLS. W pierwszej wiadomości, ClientHello, mówi przez SNI: „sklep.test”.
- Apache patrzy na listę VirtualHostów na
*:443, znajduje ten zServerName sklep.testalboServerAlias, i pokazuje jego certyfikat. - Klient sprawdza legitymację: czy nazwa jest w SAN, czy termin nie minął, czy pieczątka jest od urzędu z listy zaufanych. To możesz zrobić sam, krok po kroku:
openssl s_client -connect 127.0.0.1:443 -servername blog.test </dev/null 2>/dev/null | grep -E 'subject=|issuer=|Verify return'
openssl s_client -connect 127.0.0.1:443 -servername sklep.test </dev/null 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName -datessubject=CN = blog.test
issuer=O = Moja Firma, CN = Moja Firma - lokalny CA
Verify return code: 0 (ok)
subject=CN = sklep.test
X509v3 Subject Alternative Name:
DNS:sklep.test, DNS:www.sklep.test
notBefore=Nov 4 01:41:11 2026 GMT
notAfter=Nov 4 01:41:11 2027 GMTTo samo IP, inny -servername, inny certyfikat. To jest SNI na żywo. 5. Dopiero po ustaleniu szyfrowania leci właściwe żądanie HTTP z nagłówkiem Host: i Apache wybiera DocumentRoot. A co, jeśli gość poda nazwę, której nie ma na żadnym szyldzie? Apache bierze pierwszego VirtualHosta na danym porcie, tego, którego apachectl -S nazwał default server. Pliki czyta alfabetycznie, a blog jest przed sklep:
curl -sI -H 'Host: nieznana.test' http://127.0.0.1/ | grep -E 'HTTP|Location'HTTP/1.1 301 Moved Permanently
Location: https://blog.test/Czyli ktoś, kto skieruje swoją domenę na Twój adres IP, wyświetli Twojego bloga pod swoją nazwą. Dlatego w produkcji dobrze mieć pierwszy, „łapiący wszystko” VirtualHost o nazwie zaczynającej się od 000-, który zwraca błąd. Odźwierny, który na nieznane nazwisko odpowiada „pomyłka”, zamiast prowadzić do pierwszego lepszego pokoju. [dla zaawansowanych: zamiast grzebać w /etc/hosts na kliencie możesz w curl podać --resolve sklep.test:443:10.0.0.5. Curl wtedy wysyła dobre SNI i dobry nagłówek Host, ale łączy się pod podany adres. Idealne do testów przed przełączeniem DNS.]
Ściąga
| Komenda | Flaga | Co robi |
|---|---|---|
apachectl | configtest | sprawdza składnię konfiguracji bez restartu |
apachectl | -S | pokazuje VirtualHosty i serwer domyślny dla portu |
a2ensite / a2dissite | -q | [Ubuntu] włącza / wyłącza stronę, bez gadania |
openssl req | -x509 -new -days | robi certyfikat urzędu podpisany samym sobą |
openssl x509 | -req -CA -CAkey -extfile | podpisuje wniosek pieczątką urzędu, z dodatkami jak SAN |
openssl x509 | -checkend SEKUNDY | kod 0, jeśli certyfikat przeżyje podany czas |
openssl s_client | -servername | łączy się z TLS i wysyła SNI |
curl | -w '%{http_code}' | wypisuje kod odpowiedzi do skryptu |
curl | --resolve NAZWA:PORT:IP | łączy pod wskazany adres bez zmiany DNS |
certbot | --apache --redirect -d | certyfikat Let's Encrypt plus przekierowanie |
certbot renew | --dry-run | próba odnowienia, nic nie zmienia |
⚠ Typowy błąd
Pierwszy, najczęstszy: certyfikat jest, strona działa, a klient i tak krzyczy:
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.htmlPo polsku: „nie znam urzędu, który to podstemplował”. Serwer jest w porządku. Problem jest u klienta, który nie ma Twojego firmowego urzędu na liście zaufanych. Ja wywołałem go specjalnie, podając curlowi listę urzędów bez naszej firmowej pieczątki. Rozwiązanie: zainstaluj ca.crt na kliencie, tak jak skrypt robi to na serwerze. Rozwiązanie złe: curl -k albo w przeglądarce „akceptuję ryzyko” na zawsze. Wtedy nie odróżnisz własnego serwera od kogoś, kto się pod niego podszywa. Drugi: zapomniany moduł. Na Ubuntu wystarczy, że ktoś wyłączy ssl, i dostajesz:
AH00526: Syntax error on line 12 of /etc/apache2/sites-enabled/blog.test.conf:
Invalid command 'SSLEngine', perhaps misspelled or defined by a module not included in the server configurationApache nie zna słowa SSLEngine, bo zna je dopiero moduł. sudo a2enmod ssl. [RHEL] sudo dnf install mod_ssl. Ten sam komunikat z Header znaczy brak modułu headers. Trzeci: ścieżka do certyfikatu z literówką albo plik skasowany:
AH00526: Syntax error on line 13 of /etc/apache2/sites-enabled/blog.test.conf:
SSLCertificateFile: file '/etc/ssl/strony/blog.test/cert.pem' does not exist or is emptyI tu widać, po co jest configtest przed przeładowaniem: z tym błędem restart położyłby obie strony, a nie tylko bloga. Jeden zepsuty szyld i cały hotel zamknięty.
✎ Zadanie
Dodaj skryptem trzecią stronę intranet.test. Potem w jej VirtualHoście na porcie 443 zabezpiecz folder /admin hasłem: plik z hasłami zrób przez htpasswd, a w konfiguracji użyj bloku <Location /admin> z AuthType Basic i Require valid-user. Sprawdź curl-em: bez hasła ma być kod 401, ze złym hasłem 401, z dobrym 200, a strona główna dalej 200. Na koniec przepuść wszystkie trzy strony przez sprawdz-strony.sh.
Pokaż rozwiązanie
Trzecia strona z panelem na hasło. Najpierw strona, tym samym skryptem co ostatnio, i folder panelu:
sudo ./dwie-strony.sh -H intranet.test
sudo mkdir -p /var/www/intranet.test/public/admin
echo '<h1>Panel</h1>' | sudo tee /var/www/intranet.test/public/admin/index.htmlTeraz lista gości z hasłami. htpasswd jest w pakiecie [Ubuntu] apache2-utils, [RHEL] httpd-tools. Flaga -c tworzy nowy plik, -B używa algorytmu bcrypt, a -b bierze hasło z linii komend. To ostatnie jest wygodne na nagraniu, ale hasło zostaje w historii powłoki, więc na serwerze pomiń -b i wpisz hasło, gdy zapyta.
sudo htpasswd -cbB /etc/apache2/.htpasswd-intranet szef 'Tajne.Haslo1'
sudo chown root:www-data /etc/apache2/.htpasswd-intranet
sudo chmod 640 /etc/apache2/.htpasswd-intranetAdding password for user szefDomyślnie plik wychodzi z prawami 644, czyli czyta go każdy użytkownik systemu. Hasze bcrypt trudno złamać, ale po co ułatwiać? Dlatego chown i chmod 640: czyta root i grupa Apache. [RHEL] plik w /etc/httpd/, grupa apache. Do VirtualHosta na porcie 443, przed ErrorLog, dopisujesz ochroniarza z listą:
<Location /admin>
AuthType Basic
AuthName "Panel intranetu"
AuthUserFile /etc/apache2/.htpasswd-intranet
Require valid-user
</Location>I sprawdzenie, w kolejności: bez hasła, ze złym hasłem, z dobrym, strona główna.
sudo apachectl configtest && sudo systemctl reload apache2
curl -s -o /dev/null -w '%{http_code}\n' https://intranet.test/admin/
curl -s -o /dev/null -w '%{http_code}\n' -u szef:zle https://intranet.test/admin/
curl -s -u 'szef:Tajne.Haslo1' https://intranet.test/admin/
curl -s -o /dev/null -w '%{http_code}\n' https://intranet.test/401
401
<h1>Panel</h1>
200A w dzienniku błędów strony został ślad po próbie ze złym hasłem, który kiedyś przeczyta fail2ban:
[auth_basic:error] [client 127.0.0.1:36182] AH01617: user szef: authentication failure for "/admin/": Password MismatchNa koniec ./sprawdz-strony.sh sklep.test blog.test intranet.test i trzy razy po trzy zielone [ OK ]. Hasło leci zakodowane, ale niezaszyfrowane, więc Basic Auth ma sens tylko na HTTPS. Na zwykłym HTTP to jak szeptanie hasła do ochroniarza przez megafon.
Co dalej
Mamy serwer, mamy strony, mamy certyfikaty. Czyli mamy coś, co szkoda stracić. W następnym projekcie budujemy system kopii zapasowych: archiwa z rotacją, raport po każdym przebiegu i, co najważniejsze, automatyczny test, czy z tej kopii da się cokolwiek odtworzyć.