← back to /blog← powrót do /blog

→ read in english

Własne CA w domowym labie: step-ca na QNAP-ie i koniec z klikaniem "i tak przejdź"

Każdy, kto ma w domu cokolwiek na HTTPS-ie (Vaultwardena, Proxmoxa, NAS-a, Home Assistanta), zna ten moment: przeglądarka krzyczy na czerwono, a Ty po raz setny klikasz “Zaawansowane → Przejdź dalej (niebezpieczne)”. Po miesiącu przestajesz czytać te ostrzeżenia, a to jest dokładnie ten nawyk, którego nie chcesz mieć.

Let’s Encrypt tu nie pomoże: nie wystawi certu na *.cygal.lan, bo tej domeny nie ma w publicznym DNS-ie i nie da się jej zwalidować. Zostają dwa wyjścia: wieczne self-signed albo własne CA. Postawiłem własne i od tamtej pory w domu jest zielona kłódka wszędzie tam, gdzie powinna być.

Poniżej dokładnie to, co u mnie stoi, w formie do przeklejenia.

Co stoi u mnie

  • QNAP „Selene” (192.168.100.210), a na nim Container Station (czyli zwykły Docker z ładnym UI).
  • Kontener smallstep/step-ca na porcie 9000, czyli całe CA, nazwane Cygal-Home-CA.
  • Kontener caddy:2 w dwóch rolach: front HTTPS dla Vaultwardena (port 8443) i serwer statyczny ze stroną self-service do instalacji root CA (port 8088).
  • Domena wewnętrzna: *.cygal.lan.

Całość to dwa kontenery i jeden plik konfiguracyjny. Naprawdę nie trzeba do tego klastra ani dedykowanej maszyny.

Krok 1: step-ca w kontenerze

W Container Station (albo zwykłym docker compose, bez różnicy) wrzucasz:

services:
  step-ca:
    image: smallstep/step-ca:latest
    container_name: step-ca
    restart: unless-stopped
    ports:
      - "9000:9000"
    volumes:
      - /share/CACHEDEV1_DATA/step-ca:/home/step
    environment:
      DOCKER_STEPCA_INIT_NAME: "Cygal-Home-CA"
      DOCKER_STEPCA_INIT_DNS_NAMES: "selene.cygal.lan,192.168.100.210,localhost"
      DOCKER_STEPCA_INIT_PROVISIONER_NAME: "admin"

Dwie rzeczy, które warto zrobić dobrze od razu:

DOCKER_STEPCA_INIT_DNS_NAMES wpisz komplet. To są nazwy, pod którymi CA samo siebie wystawia. Jeśli dziś chodzisz po IP, a jutro dodasz DNS, to bez tej nazwy na liście dostaniesz błąd TLS przy każdym step i będziesz musiał przeinicjować CA. U mnie są trzy: FQDN, IP i localhost.

Hasło. Przy pierwszym starcie kontener wygeneruje hasło do klucza CA i wypisze je w logach. Wyciągnij je i schowaj w menedżerze haseł (u mnie: w tym samym Vaultwardenie, który potem tym CA zabezpieczam; tak, wiem):

docker logs step-ca 2>&1 | grep -i password

Sprawdzenie, że żyje:

docker exec step-ca step ca health --ca-url https://localhost:9000 \
  --root /home/step/certs/root_ca.crt

Teraz najważniejsza liczba w całej zabawie: fingerprint roota. To on zastępuje zaufanie do kanału: certyfikat możesz rozdawać choćby po zwykłym HTTP, byle odbiorca porównał fingerprint.

docker exec step-ca step certificate fingerprint /home/step/certs/root_ca.crt

Zapisz go sobie, będzie potrzebny w każdym kolejnym kroku.

Krok 2: pierwszy cert dla usługi

Domyślnie step-ca stawia provisioner typu JWK (u mnie nazwany admin), czyli „żeby dostać cert, podaj hasło”. Wystawienie certu dla Vaultwardena:

docker exec -it step-ca step ca certificate \
  "vault.cygal.lan" \
  /home/step/certs/vw.crt /home/step/secrets/vw.key \
  --provisioner admin \
  --not-after 8760h

8760h to rok. Wrócę do tego niżej, bo to jest właśnie to miejsce, gdzie mój setup ma dziś dług.

Cert plus klucz kopiujesz tam, gdzie ich potrzebuje usługa. U mnie do katalogu montowanego do Caddy’ego jako /certs.

Krok 3: Caddy jako front HTTPS

Vaultwarden siedzi na zwykłym HTTP w kontenerze, a HTTPS-em zajmuje się Caddy. Mój Caddyfile, w całości:

{
	admin off
	auto_https off
}

# Strona self-service do instalacji root CA (HTTP, bo nowe urządzenia jeszcze
# nie ufają CA; integralność chroni weryfikacja fingerprintu w skryptach).
# Publikowane na hoście jako :8088.
:80 {
	root * /srv
	file_server
}

# Front-end HTTPS dla Vaultwardena.
# Statyczny cert wystawiony przez step-ca (Cygal-Home-CA), ważny 1 rok.
# Publikowane na hoście jako :8443.
:443 {
	tls /certs/vw.crt /certs/vw.key
	encode gzip

	reverse_proxy 192.168.100.210:32787 {
		header_up X-Real-IP {remote_host}
	}
}

Jedna linijka wymaga komentarza: auto_https off. Caddy domyślnie jest sprytny i sam próbuje załatwić certy z Let’s Encrypt. W sieci .lan to się nie uda i skończy się pętlą błędów w logach. Skoro cert dostarczam ręcznie, wyłączam automat.

Krok 4: rozdanie root CA po całym domu

To jest ta część, którą najczęściej się olewa, a bez niej całe CA nie ma sensu: każde urządzenie musi mieć Twój root w trust storze. Laptop, telefon żony, służbowy Mac, Raspberry Pi, konsola.

Zrobiłem sobie prostą stronę self-service: statyczny HTML serwowany przez Caddy’ego na :8088, z rootem, skryptami instalacyjnymi i fingerprintem na wierzchu. Wchodzisz z nowego urządzenia, klikasz swoją platformę, wklejasz jedną komendę. Struktura katalogu /srv:

/srv
├── index.html
├── root_ca.crt
├── install-linux.sh
├── install-macos.sh
└── install-windows.ps1

Skrypt dla Linuxa. Sedno sprawy to weryfikacja fingerprintu, bo dzięki niej transport po HTTP jest w porządku:

#!/usr/bin/env bash
set -euo pipefail

CERT_NAME="cygal-home-ca-root"
EXPECTED_FP="<TU_WKLEJ_SWÓJ_FINGERPRINT>"
CERT_URL="${CERT_URL:-http://192.168.100.210:8088/root_ca.crt}"

[ "$(id -u)" -eq 0 ] || { echo "Uruchom przez sudo" >&2; exit 1; }

TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT
curl -fsSL "$CERT_URL" -o "$TMP"

FP="$(openssl x509 -in "$TMP" -noout -fingerprint -sha256 \
      | sed 's/.*=//; s/://g' | tr 'A-F' 'a-f')"
if [ "$FP" != "$EXPECTED_FP" ]; then
	echo "BŁĄD: fingerprint się nie zgadza!" >&2
	exit 1
fi
echo "Fingerprint OK: $FP"

if [ -d /usr/local/share/ca-certificates ] && command -v update-ca-certificates >/dev/null; then
	install -m 0644 "$TMP" "/usr/local/share/ca-certificates/${CERT_NAME}.crt"
	update-ca-certificates
elif [ -d /etc/pki/ca-trust/source/anchors ] && command -v update-ca-trust >/dev/null; then
	install -m 0644 "$TMP" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt"
	update-ca-trust extract
else
	echo "BŁĄD: nieznana dystrybucja." >&2
	exit 1
fi

echo "OK: root CA zainstalowany."

Użycie z dowolnego domowego kompa:

curl -fsSL http://192.168.100.210:8088/install-linux.sh | sudo bash

macOS (System Keychain, poprosi o hasło admina):

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain root_ca.crt

Windows, PowerShell jako Administrator:

Import-Certificate -FilePath .\root_ca.crt `
  -CertStoreLocation Cert:\LocalMachine\Root

Jeśli na maszynie masz step CLI, całość załatwia jedna komenda, która pobiera root, sprawdza fingerprint i instaluje do systemu:

step ca bootstrap \
  --ca-url https://192.168.100.210:9000 \
  --fingerprint <TU_WKLEJ_SWÓJ_FINGERPRINT> \
  --install

Trzy pułapki, na które i tak wpadniesz

  1. Firefox ma własny trust store i systemowy go nie obchodzi. Albo about:configsecurity.enterprise_roots.enabledtrue, albo ręczny import w Ustawieniach. Chrome, Edge i Safari biorą z systemu.
  2. iOS ma dwa kroki. Instalacja profilu to dopiero połowa: potem Ustawienia → Ogólne → Informacje → Zaufanie certyfikatów i tam trzeba ręcznie przełączyć suwak. Bez tego cert jest zainstalowany i… ignorowany.
  3. Androidowe „user certificates” są ignorowane przez aplikacje (tylko przeglądarka je uzna), o ile aplikacja nie deklaruje inaczej. Na telefonie nastaw się na przeglądarkę.

Krok 5: automatyzacja odnawiania (to mój następny krok)

Uczciwie: u mnie tego jeszcze nie ma. Mam certy roczne i wystawiane ręcznie, czyli klasyczny tykający zegar. Za jedenaście miesięcy zapomnę, że to w ogóle istnieje, i Vaultwarden padnie w najmniej wygodnym momencie. Tak to zamierzam naprawić i tak to poleciłbym zrobić od razu, jeśli zaczynasz od zera.

Klucz to ACME. step-ca umie być własnym Let’s Encryptem. Dodajesz provisioner:

docker exec -it step-ca step ca provisioner add acme --type ACME
docker restart step-ca

(Przy stawianiu od zera wystarczy DOCKER_STEPCA_INIT_ACME: "true" w compose.)

I nagle Caddy potrafi wszystko sam, bez ręcznych plików .crt:

{
	acme_ca https://192.168.100.210:9000/acme/acme/directory
	acme_ca_root /certs/root_ca.crt
}

vault.cygal.lan {
	reverse_proxy 192.168.100.210:32787
}

Caddy sam wystawi cert, sam go odnowi, sam przeładuje konfigurację. Zniknie cały krok 2 i połowa moich pilnowanek.

Krok 6: certy dla samych maszyn (i jak się same odnawiają)

Certy dla usług za Caddym to jedno, ale prędzej czy później chcesz cert na samej maszynie: do mTLS między usługami, do Prometheusa, do panelu Proxmoxa, do syslogu po TLS. Tu Caddy niczego nie załatwi; host musi umieć wystawić i odnowić cert sam.

Najpierw step CLI z oficjalnego repo (Debian/Ubuntu):

sudo apt-get update && sudo apt-get install -y --no-install-recommends curl gpg ca-certificates
sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg \
  -o /etc/apt/keyrings/smallstep.asc
cat <<'EOF' | sudo tee /etc/apt/sources.list.d/smallstep.sources
Types: deb
URIs: https://packages.smallstep.com/stable/debian
Suites: debs
Components: main
Signed-By: /etc/apt/keyrings/smallstep.asc
EOF
sudo apt-get update && sudo apt-get install -y step-cli

Potem bootstrap do CA, czyli to samo polecenie co w kroku 4: pobiera root, weryfikuje fingerprint i instaluje go do systemu:

sudo step ca bootstrap \
  --ca-url https://192.168.100.210:9000 \
  --fingerprint <TU_WKLEJ_SWÓJ_FINGERPRINT> \
  --install

I wystawiasz cert dla hosta:

sudo mkdir -p /etc/step/certs
sudo step ca certificate "$(hostname -f)" \
  "/etc/step/certs/$(hostname -s).crt" \
  "/etc/step/certs/$(hostname -s).key" \
  --provisioner admin

Teraz rzecz, przez którą to w ogóle nadaje się do automatu: odnowienie nie wymaga hasła do provisionera. step ca renew uwierzytelnia się dotychczasowym certyfikatem (mTLS), więc na maszynie nie musi leżeć żaden sekret CA. Dzięki temu automatyczny odnawiacz jest bezpieczny nawet na laptopie, który wyjeżdża z domu.

Smallstep ma na to gotowy wzorzec z szablonami unitów. Plik /etc/systemd/system/cert-renewer@.service:

[Unit]
Description=Certificate renewer for %I
After=network-online.target
Documentation=https://smallstep.com/docs/step-ca/renewal
StartLimitIntervalSec=0

[Service]
Type=oneshot
User=root
Environment=STEPPATH=/etc/step-ca \
            CERT_LOCATION=/etc/step/certs/%i.crt \
            KEY_LOCATION=/etc/step/certs/%i.key

; Odnawiaj tylko wtedy, gdy faktycznie trzeba.
ExecCondition=/usr/bin/step certificate needs-renewal ${CERT_LOCATION}
ExecStart=/usr/bin/step ca renew --force ${CERT_LOCATION} ${KEY_LOCATION}

; Przeładuj usługę, która z tego certu korzysta (jeśli istnieje).
ExecStartPost=/usr/bin/env sh -c "! systemctl --quiet is-active %i.service || systemctl try-reload-or-restart %i"

[Install]
WantedBy=multi-user.target

I timer /etc/systemd/system/cert-renewer@.timer, który sprawdza co 15 minut, czy już czas; o to, żeby nie odnawiać za wcześnie, dba ExecCondition wyżej:

[Unit]
Description=Timer for certificate renewal of %I

[Timer]
Persistent=true
OnCalendar=*:1/15
AccuracySec=1us
RandomizedDelaySec=5m

[Install]
WantedBy=timers.target

Włączasz per usługa, która z certu korzysta. Nazwa po @ to nazwa tej usługi, bo unit sam ją potem przeładuje:

sudo systemctl enable --now cert-renewer@nginx.timer
systemctl list-timers | grep cert-renewer

Kontrola, czy to faktycznie żyje:

step certificate inspect --short /etc/step/certs/$(hostname -s).crt
sudo systemctl status cert-renewer@nginx.service

Jedna pułapka, konkretnie na laptopy. step ca renew działa dopóki stary cert jeszcze żyje: to on jest przepustką. Jeśli maszyna była wyłączona dłużej niż okres ważności, odnowienie się nie uda i trzeba wystawić cert od zera (znów step ca certificate z provisionerem). Przy rocznych certach to nieistotne, przy 24-godzinnych laptop wróci z wakacji z martwym certem. Dobierz --not-after do tego, jak sprzęt naprawdę bywa włączony: serwery krótko, laptopy z zapasem.

I na koniec: skróć czas życia certów. Rok to relikt świata ręcznych certów. Jak odnawianie chodzi samo, zejdź do tygodnia albo doby. Krótki cert to mniejsze okno, gdyby klucz wyciekł, i natychmiastowa informacja, że automat się zepsuł, zamiast cichej bomby zegarowej odpalającej się za rok.

Skąd wiem, że to wszystko faktycznie działa

Piszę sobie do tego narzędzie certops, bo ręczne sprawdzanie openssl s_client przestaje się skalować przy trzeciej usłudze. Sprawdza CA (m.in. providera Smallstep), certy na endpointach, chain, daty i trust store’y, lokalnie i po SSH na całej flocie:

certops check vault.cygal.lan:8443
certops fleet trust verify -f certops.yaml

fleet trust verify odpowiada dokładnie na pytanie „czy każda maszyna w domu ma mój root i czy wszystkie mają ten sam”, czyli na to, co przy ręcznym rozdawaniu certów najłatwiej przegapić.

Podsumowanie

Efekt po jednym wieczorze: dwa kontenery, jeden Caddyfile, jedna strona self-service. Zielona kłódka na wszystkim w domu, zero klikania „i tak przejdź”, i, co dla mnie najważniejsze, odruch, że czerwony ekran naprawdę oznacza problem, a nie codzienną niedogodność do przeklikania.

Jeśli zaczynasz od zera, zrób jedną rzecz inaczej niż ja: włącz ACME od razu. Reszta jest do przeklejenia z góry.

- Paweł


← back to /blog← powrót do /blog

→ read in english