2026.03.15 - 47-dniowe certyfikaty

Intro

Z wejściem nowych czasów trwania certyfikatów oraz walidacji właścicieli domen postanowiłem przyjrzeć się automatyzacji całego procesu obsługi certów.

Co się zmieni?

W kwietniu 2025 CAB Forum przyjęło nowe zasady odświeżania certyfikatów oraz cachowania danych walidacyjnych. Zmiany będą następowały etapami.

https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/

Czas życia certyfikatu

Pierwszą zmianą, która uprzykrzy ręczną rotację certyfikatów, jest sam okres ważności. Certyfikaty wystawiane od 15 marca 2026 będą coraz krótsze, aż do docelowej wartości 47 dni.

6.3.2 Certificate operational periods and key pair usage periods (strona 85)

Certificate issued on or after Certificate issued before Maximum Validity Period
- March 15, 2026 398 days
March 15, 2026 March 15, 2027 200 days
March 15, 2027 March 15, 2029 100 days
March 15, 2029 - 47 days

Certum wykazało się nadgorliwością i u nich certyfikaty są krótsze już od 13 marca (w końcu piątek 13-go) :)

Certum SSL Info
Certum SSL Info

Walidacja właściciela domeny

Drugą zmianą jest walidacja właściciela domeny. Aktualnie wystawca certyfikatów (CA) po walidacji właściciela przechowywał informacje ponad rok bez wymogu ponownej weryfikacji. Powodowało to problemy, jeśli domena zmieniła właściciela, stary mógł nadal wystawić sobie certyfikat :)

4.2.1 Performing identification and authentication functions (strona 61)

Certificate issued on or after Certificate issued before Maximum data reuse period
- March 15, 2026 398 days
March 15, 2026 March 15, 2027 200 days
March 15, 2027 March 15, 2029 100 days
March 15, 2029 - 10 days

W praktyce oznacza to, że po upływie 10 dni trzeba będzie weryfikować własność domeny. Przy 47-dniowym certyfikacie (gdzie odświeżanie będzie pewnie w okolicach 30 dni), oznacza to weryfikację przy każdym odnowieniu.

Na pierwszy rzut oka może wyglądać, że nie jest dobrze, ale całe to zamieszanie jest dla osiągnięcia dwóch celów: częstej rotacji kluczy (to akurat zależy od klienta czy będzie rotował prywatny) oraz wymuszenia automatyzacji.

Na pewno problematyczne będą stare systemy, gdzie przewidziana była jedynie wymiana ręczna przez GUI.

Analiza i wybór rozwiązania

Wiedząc już, że czas upierdliwości nadszedł, trzeba rozejrzeć się, co aktualnie jest "na rynku". GooglyEyes podpowiada: certbot, LEGO i acme.sh. Z tych trzech nieznany mi jest tylko LEGO (przynajmniej w tej postaci, preferuje plastikową). Każdy z klientów implementuje RFC 8555.

Wybór padł na acme.sh z następujących powodów:

  • napisany w shell'u, więc łatwo modyfikowalny na moje potrzeby
  • używany i sprawdzony "w polu" przez Proxmox'a
  • wspiera PVE, PBS, TrueNAS, haproxy, nginx i Synology z pudełka (via deploy skrypty)
  • obsługuje http-01 (za reverse proxy) i dns-01 (z wieloma dostawcami) challenges
  • obsługuje wielu wystawców (ZeroSSL, Let's Encrypt)

Pomysł na automatyzację jest następujący:

  • zarządzanie certyfikatami z jednego miejsca
  • jednorazowe generowanie certów, użycie na wielu maszynach (separacja issue od deploy)
  • osobny "timer" do generowania i osobny do deploy
  • deploy z użyciem natywnych skryptów lub Ansible
  • obsługa wildcard'ów
  • jednoczesna obsługa RSA i ECC

Instalacja

Instalację można wykonać na kilka sposobów. Pierwszym jest użycie gotowego skryptu instalacyjnego, drugim natomiast sklonowanie repozytorium git i instalacja lokalna.

Wybrałem opcję z klonem repo. Po zalogowaniu się na serwerze kontem zwykłego usera zainstalowałem acme.sh.

# Install from Git
git clone https://github.com/acmesh-official/acme.sh.git
cd ./acme.sh
./acme.sh --install -m acme@nerdoza.studio

Po instalacji przelogowałem się na serwerze, aby alias acme.sh zaczął prawidłowo działać.

Test http-01

Pierwsze wystawienie certu będzie zrealizowane z użyciem metody http-01. acme.sh wspiera tę metodę uruchamiając własny serwerek HTTP na dowolnym porcie. Dla testu przekierowałem na tunelu CloudFlare prefix URI /.well-known/.acme-challenge/ na maszynkę wirtualną z acme.sh.

Tryb --test obsługiwany jest tylko przez Let's Encrypt.

# run standalone webserver on port 8080
# @see https://github.com/acmesh-official/acme.sh/wiki/Options-and-Params
acme.sh --issue -d acme.nerdoza.studio -k ec-256 --standalone --httpport 8080 --force
[Tue Mar 15 08:38:05 PM CET 2026] Using CA: https://acme.zerossl.com/v2/DV90
[Tue Mar 15 08:38:05 PM CET 2026] Standalone mode.
[Tue Mar 15 08:38:05 PM CET 2026] Creating domain key
[Tue Mar 15 08:38:05 PM CET 2026] The domain key is here: /home/user/.acme.sh/acme.nerdoza.studio_ecc/acme.nerdoza.studio.key
[Tue Mar 15 08:38:05 PM CET 2026] Generating next pre-generate key.
[Tue Mar 15 08:38:05 PM CET 2026] Single domain='acme.nerdoza.studio'
[Tue Mar 15 08:38:07 PM CET 2026] Getting webroot for domain='acme.nerdoza.studio'
[Tue Mar 15 08:38:07 PM CET 2026] Verifying: acme.nerdoza.studio
[Tue Mar 15 08:38:07 PM CET 2026] Standalone mode server
[Tue Mar 15 08:38:08 PM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Tue Mar 15 08:38:12 PM CET 2026] Success
[Tue Mar 15 08:38:12 PM CET 2026] Verification finished, beginning signing.
[Tue Mar 15 08:38:12 PM CET 2026] Let's finalize the order.
[Tue Mar 15 08:38:12 PM CET 2026] Le_OrderFinalize='https://acme.zerossl.com/v2/DV90/order/xxxxx-g/finalize'
[Tue Mar 15 08:38:12 PM CET 2026] Order status is 'processing', let's sleep and retry.
[Tue Mar 15 08:38:12 PM CET 2026] Sleeping for 15 seconds then retrying
[Tue Mar 15 08:38:28 PM CET 2026] Polling order status: https://acme.zerossl.com/v2/DV90/order/xxxxx-g
[Tue Mar 15 08:38:28 PM CET 2026] Downloading cert.
[Tue Mar 15 08:38:28 PM CET 2026] Le_LinkCert='https://acme.zerossl.com/v2/DV90/cert/xxxxxx'
[Tue Mar 15 08:38:29 PM CET 2026] Cert success.
-----BEGIN CERTIFICATE-----
MIIEADC...
-----END CERTIFICATE-----
[Tue Mar 15 08:38:29 PM CET 2026] Your cert is in: /home/user/.acme.sh/acme.nerdoza.studio_ecc/acme.nerdoza.studio.cer
[Tue Mar 15 08:38:29 PM CET 2026] Your cert key is in: /home/user/.acme.sh/acme.nerdoza.studio_ecc/acme.nerdoza.studio.key
[Tue Mar 15 08:38:29 PM CET 2026] The intermediate CA cert is in: /home/user/.acme.sh/acme.nerdoza.studio_ecc/ca.cer
[Tue Mar 15 08:38:29 PM CET 2026] And the full-chain cert is in: /home/user/.acme.sh/acme.nerdoza.studio_ecc/fullchain.cer

Generowanie pojedynczego certyfikatu działa bez zarzutu. Próba wygenerowania wildcard'a daje spodziewany efekt.

[Tue Mar 17 08:53:35 PM CET 2026] Single domain='*.nerdoza.studio'
[Tue Mar 17 08:53:36 PM CET 2026] Getting webroot for domain='*.nerdoza.studio'
[Tue Mar 17 08:53:37 PM CET 2026] Cannot get domain token entry *.nerdoza.studio for http-01
[Tue Mar 17 08:53:37 PM CET 2026] Supported validation types are: dns-01 , but you specified: http-01

Test dns-01

Do obsługi certyfikatów wildcard trzeba użyć trybu dns-01. Jest to obecnie jedyny sposób wykazania, że jest się właścicielem całej strefy, a nie tylko pojedynczych nazw. acme.sh wspiera używane przeze mnie usługi: CloudFlare oraz OVHcloud.

Tutorial do OVH znajduje się na stronie wiki How to use OVH domain api.

Dodatkowo przejrzałem dokumentację i kody:

Generowanie klucza API OVHcloud

Po zalogowaniu się w panelu OVH przeszedłem na stronę do zarządzania kluczami API.

OVH API Keys
OVH API Keys
OVH API Keys
OVH API Keys

Dodałem uprawnienia do zarządzania strefą medynski.ovh. Dodatkowo ograniczyłem użycie klucza do wybranego zakresu IP.

OVH API Keys
OVH API Keys

Można też skorzystać z linku, który od razu ustawi wymagane uprawnienia w formularzu.

Po wciśnięciu przycisku Create dostaniemy wszystkie wymagane wartości dla dns_api.

OVH API Keys
OVH API Keys

Ustawienie zmiennych

Zgodnie z opisem dnsapi/dns_ovh.sh ustawiłem zmienne środowiskowe.

# endpoint (eu is default)
export export OVH_END_POINT=ovh-eu

# application key
export OVH_AK="your application key"

# application secret
export OVH_AS="your application secret"

# consumer key
export OVH_CK="your consumer key"

Generowanie certyfikatu

Wygenerowałem certyfikat wildcard korzystając z weryfikacji domeny via API OVHcloud.

# sleep added to manually check DNS zone in OVH admin panel
# @see https://github.com/acmesh-official/acme.sh/wiki/Options-and-Params
acme.sh --issue --dns dns_ovh --dnssleep 60 -d blog.medynski.ovh -d *.blog.medynski.ovh --force
[Thu Mar 19 01:44:49 AM CET 2026] Using CA: https://acme.zerossl.com/v2/DV90
[Thu Mar 19 01:44:49 AM CET 2026] Multi domain='DNS:blog.medynski.ovh,DNS:*.blog.medynski.ovh'
[Thu Mar 19 01:44:51 AM CET 2026] Getting webroot for domain='blog.medynski.ovh'
[Thu Mar 19 01:44:51 AM CET 2026] Getting webroot for domain='*.blog.medynski.ovh'
[Thu Mar 19 01:44:51 AM CET 2026] Adding TXT value: Gkwxxxxx for domain: _acme-challenge.blog.medynski.ovh
[Thu Mar 19 01:44:51 AM CET 2026] Using OVH endpoint: ovh-eu
[Thu Mar 19 01:44:51 AM CET 2026] Checking authentication
[Thu Mar 19 01:44:51 AM CET 2026] Consumer key is ok.
[Thu Mar 19 01:44:52 AM CET 2026] Adding record
[Thu Mar 19 01:44:53 AM CET 2026] Added, sleep 10 seconds.
[Thu Mar 19 01:45:04 AM CET 2026] The TXT record has been successfully added.
[Thu Mar 19 01:45:04 AM CET 2026] Adding TXT value: Cg8xxxxx for domain: _acme-challenge.blog.medynski.ovh
[Thu Mar 19 01:45:04 AM CET 2026] Using OVH endpoint: ovh-eu
[Thu Mar 19 01:45:04 AM CET 2026] Checking authentication
[Thu Mar 19 01:45:04 AM CET 2026] Consumer key is ok.
[Thu Mar 19 01:45:05 AM CET 2026] Adding record
[Thu Mar 19 01:45:06 AM CET 2026] Added, sleep 10 seconds.
[Thu Mar 19 01:45:17 AM CET 2026] The TXT record has been successfully added.
[Thu Mar 19 01:45:17 AM CET 2026] Sleeping for 60 seconds to wait for the the TXT records to take effect
[Thu Mar 19 01:46:18 AM CET 2026] Verifying: blog.medynski.ovh
[Thu Mar 19 01:46:19 AM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Thu Mar 19 01:46:22 AM CET 2026] Success
[Thu Mar 19 01:46:22 AM CET 2026] Verifying: *.blog.medynski.ovh
[Thu Mar 19 01:46:22 AM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Thu Mar 19 01:46:26 AM CET 2026] Success
[Thu Mar 19 01:46:26 AM CET 2026] Removing DNS records.
[Thu Mar 19 01:46:26 AM CET 2026] Removing txt: Gkwxxxxx for domain: _acme-challenge.blog.medynski.ovh
[Thu Mar 19 01:46:26 AM CET 2026] Using OVH endpoint: ovh-eu
[Thu Mar 19 01:46:26 AM CET 2026] Checking authentication
[Thu Mar 19 01:46:26 AM CET 2026] Consumer key is ok.
[Thu Mar 19 01:46:28 AM CET 2026] Successfully removed
[Thu Mar 19 01:46:28 AM CET 2026] Removing txt: Cg8xxxxx for domain: _acme-challenge.blog.medynski.ovh
[Thu Mar 19 01:46:29 AM CET 2026] Using OVH endpoint: ovh-eu
[Thu Mar 19 01:46:29 AM CET 2026] Checking authentication
[Thu Mar 19 01:46:29 AM CET 2026] Consumer key is ok.
[Thu Mar 19 01:46:31 AM CET 2026] Successfully removed
[Thu Mar 19 01:46:31 AM CET 2026] Verification finished, beginning signing.
[Thu Mar 19 01:46:31 AM CET 2026] Let's finalize the order.
[Thu Mar 19 01:46:31 AM CET 2026] Le_OrderFinalize='https://acme.zerossl.com/v2/DV90/order/xxxxx/finalize'
[Thu Mar 19 01:46:31 AM CET 2026] Order status is 'processing', let's sleep and retry.
[Thu Mar 19 01:46:31 AM CET 2026] Sleeping for 15 seconds then retrying
[Thu Mar 19 01:46:47 AM CET 2026] Polling order status: https://acme.zerossl.com/v2/DV90/order/xxxxx
[Thu Mar 19 01:46:48 AM CET 2026] Downloading cert.
[Thu Mar 19 01:46:48 AM CET 2026] Le_LinkCert='https://acme.zerossl.com/v2/DV90/cert/xxxxx'
[Thu Mar 19 01:46:48 AM CET 2026] Cert success.
-----BEGIN CERTIFICATE-----
MIIED...
-----END CERTIFICATE-----
[Thu Mar 19 01:46:48 AM CET 2026] Your cert is in: /home/user/.acme.sh/blog.medynski.ovh_ecc/blog.medynski.ovh.cer
[Thu Mar 19 01:46:48 AM CET 2026] Your cert key is in: /home/user/.acme.sh/blog.medynski.ovh_ecc/blog.medynski.ovh.key
[Thu Mar 19 01:46:48 AM CET 2026] The intermediate CA cert is in: /home/user/.acme.sh/blog.medynski.ovh_ecc/ca.cer
[Thu Mar 19 01:46:48 AM CET 2026] And the full-chain cert is in: /home/user/.acme.sh/blog.medynski.ovh_ecc/fullchain.cer

W trakcie działania skryptu sprawdziłem wpisy w strefie medynski.ovh.

OVH DNS Zone
OVH DNS Zone

Po wygenerowaniu certyfikatu sprawdziłem szczegóły w KeyStore Explorer. Wygląda cudownie :)

Cert Details
Cert Details
Cert SANs
Cert SANs

Test dns-01 z wykorzystaniem aliasów

Działanie aliasów jest dobrze opisane na Wiki projektu acme.sh oraz na stronach Let's Encrypt.

Konfiguracja CloudFlare

Pierwszym krokiem jest ustawienie aliasów w CloudFlare dla domeny nerdoza.studio. Po zalogowaniu się do panelu administratora dodałem alias dla ACME. Można to zrobić na dwa sposoby, więc przetestuje obydwa. Poniżej konfiguracja DNS'ów w CloudFlare.

CloudFlare DNS Setup
CloudFlare DNS Setup

Wystawienie certyfikatu z opcją --challenge-alias

Pierwszy test korzysta z wpisu DNS _acme-challenge.test1 CNAME _acme-challenge.test1.medynski.ovh.. Po uruchomieniu komendy widać, że DNS'y ustawiane są w OVHcloud, a weryfikacja zachodzi dla domeny nerdoza.studio. Works as intended :)

OVH challenge-alias
OVH challenge-alias
# sleep added to manually check DNS zone in OVH admin panel
# @see https://github.com/acmesh-official/acme.sh/wiki/Options-and-Params
acme.sh --issue --dns dns_ovh --dnssleep 60 --challenge-alias test1.medynski.ovh -d test1.nerdoza.studio -d *.test1.nerdoza.studio --force

[Fri Mar 20 07:37:19 AM CET 2026] Using CA: https://acme.zerossl.com/v2/DV90
[Fri Mar 20 07:37:19 AM CET 2026] Creating domain key
[Fri Mar 20 07:37:19 AM CET 2026] The domain key is here: /home/user/.acme.sh/test1.nerdoza.studio_ecc/test1.nerdoza.studio.key
[Fri Mar 20 07:37:19 AM CET 2026] Multi domain='DNS:test1.nerdoza.studio,DNS:*.test1.nerdoza.studio'
[Fri Mar 20 07:37:21 AM CET 2026] Getting webroot for domain='test1.nerdoza.studio'
[Fri Mar 20 07:37:21 AM CET 2026] Getting webroot for domain='*.test1.nerdoza.studio'
[Fri Mar 20 07:37:21 AM CET 2026] Adding TXT value: AALxxxxx for domain: _acme-challenge.test1.medynski.ovh
[Fri Mar 20 07:37:21 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:37:21 AM CET 2026] Checking authentication
[Fri Mar 20 07:37:22 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:37:23 AM CET 2026] Adding record
[Fri Mar 20 07:37:23 AM CET 2026] Added, sleep 10 seconds.
[Fri Mar 20 07:37:34 AM CET 2026] The TXT record has been successfully added.
[Fri Mar 20 07:37:34 AM CET 2026] Adding TXT value: Qmmxxxxx for domain: _acme-challenge.test1.medynski.ovh
[Fri Mar 20 07:37:34 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:37:34 AM CET 2026] Checking authentication
[Fri Mar 20 07:37:35 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:37:36 AM CET 2026] Adding record
[Fri Mar 20 07:37:36 AM CET 2026] Added, sleep 10 seconds.
[Fri Mar 20 07:37:47 AM CET 2026] The TXT record has been successfully added.
[Fri Mar 20 07:37:47 AM CET 2026] Sleeping for 60 seconds to wait for the the TXT records to take effect
[Fri Mar 20 07:38:48 AM CET 2026] Verifying: test1.nerdoza.studio
[Fri Mar 20 07:38:49 AM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Fri Mar 20 07:38:52 AM CET 2026] Success
[Fri Mar 20 07:38:52 AM CET 2026] Verifying: *.test1.nerdoza.studio
[Fri Mar 20 07:38:53 AM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Fri Mar 20 07:38:56 AM CET 2026] Success
[Fri Mar 20 07:38:56 AM CET 2026] Removing DNS records.
[Fri Mar 20 07:38:56 AM CET 2026] Removing txt: AALxxxxx for domain: _acme-challenge.test1.medynski.ovh
[Fri Mar 20 07:38:56 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:38:56 AM CET 2026] Checking authentication
[Fri Mar 20 07:38:56 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:38:59 AM CET 2026] Successfully removed
[Fri Mar 20 07:38:59 AM CET 2026] Removing txt: Qmmxxxxx for domain: _acme-challenge.test1.medynski.ovh
[Fri Mar 20 07:38:59 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:38:59 AM CET 2026] Checking authentication
[Fri Mar 20 07:38:59 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:39:01 AM CET 2026] Successfully removed
[Fri Mar 20 07:39:01 AM CET 2026] Verification finished, beginning signing.
[Fri Mar 20 07:39:01 AM CET 2026] Let's finalize the order.
[Fri Mar 20 07:39:01 AM CET 2026] Le_OrderFinalize='https://acme.zerossl.com/v2/DV90/order/xxxxx/finalize'
[Fri Mar 20 07:39:02 AM CET 2026] Order status is 'processing', let's sleep and retry.
[Fri Mar 20 07:39:02 AM CET 2026] Sleeping for 15 seconds then retrying
[Fri Mar 20 07:39:18 AM CET 2026] Polling order status: https://acme.zerossl.com/v2/DV90/order/xxxxx
[Fri Mar 20 07:39:18 AM CET 2026] Downloading cert.
[Fri Mar 20 07:39:18 AM CET 2026] Le_LinkCert='https://acme.zerossl.com/v2/DV90/cert/xxxxx'
[Fri Mar 20 07:39:19 AM CET 2026] Cert success.
-----BEGIN CERTIFICATE-----
MIIEG...
-----END CERTIFICATE-----
[Fri Mar 20 07:39:19 AM CET 2026] Your cert is in: /home/user/.acme.sh/test1.nerdoza.studio_ecc/test1.nerdoza.studio.cer
[Fri Mar 20 07:39:19 AM CET 2026] Your cert key is in: /home/user/.acme.sh/test1.nerdoza.studio_ecc/test1.nerdoza.studio.key
[Fri Mar 20 07:39:19 AM CET 2026] The intermediate CA cert is in: /home/user/.acme.sh/test1.nerdoza.studio_ecc/ca.cer
[Fri Mar 20 07:39:19 AM CET 2026] And the full-chain cert is in: /home/user/.acme.sh/test1.nerdoza.studio_ecc/fullchain.cer

Wystawienie certyfikatu z opcją --domain-alias

Drugi test korzysta z wpisu _acme-challenge.test2 CNAME test2.medynski.ovh.. Po uruchomieniu komendy ponownie widać, że DNS'y ustawiane są w OVHcloud, a weryfikacja zachodzi dla domeny nerdoza.studio. Tym razem widać, że skrypt nie dodaje prefixu _acme-challenge i używa CNAME as-is.

OVH domain-alias
OVH domain-alias
# sleep added to manually check DNS zone in OVH admin panel
# @see https://github.com/acmesh-official/acme.sh/wiki/Options-and-Params
acme.sh --issue --dns dns_ovh --dnssleep 60 --domain-alias test2.medynski.ovh -d test2.nerdoza.studio -d *.test2.nerdoza.studio --force

[Fri Mar 20 07:51:29 AM CET 2026] Using CA: https://acme.zerossl.com/v2/DV90
[Fri Mar 20 07:51:29 AM CET 2026] Creating domain key
[Fri Mar 20 07:51:29 AM CET 2026] The domain key is here: /home/user/.acme.sh/test2.nerdoza.studio_ecc/test2.nerdoza.studio.key
[Fri Mar 20 07:51:29 AM CET 2026] Multi domain='DNS:test2.nerdoza.studio,DNS:*.test2.nerdoza.studio'
[Fri Mar 20 07:51:31 AM CET 2026] Getting webroot for domain='test2.nerdoza.studio'
[Fri Mar 20 07:51:31 AM CET 2026] Getting webroot for domain='*.test2.nerdoza.studio'
[Fri Mar 20 07:51:31 AM CET 2026] Adding TXT value: ORgxxxxx for domain: test2.medynski.ovh
[Fri Mar 20 07:51:31 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:51:31 AM CET 2026] Checking authentication
[Fri Mar 20 07:51:32 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:51:32 AM CET 2026] Adding record
[Fri Mar 20 07:51:33 AM CET 2026] Added, sleep 10 seconds.
[Fri Mar 20 07:51:44 AM CET 2026] The TXT record has been successfully added.
[Fri Mar 20 07:51:44 AM CET 2026] Adding TXT value: 3Dgxxxxx for domain: test2.medynski.ovh
[Fri Mar 20 07:51:44 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:51:44 AM CET 2026] Checking authentication
[Fri Mar 20 07:51:44 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:51:45 AM CET 2026] Adding record
[Fri Mar 20 07:51:46 AM CET 2026] Added, sleep 10 seconds.
[Fri Mar 20 07:51:57 AM CET 2026] The TXT record has been successfully added.
[Fri Mar 20 07:51:57 AM CET 2026] Sleeping for 60 seconds to wait for the the TXT records to take effect
[Fri Mar 20 07:52:58 AM CET 2026] Verifying: test2.nerdoza.studio
[Fri Mar 20 07:52:58 AM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Fri Mar 20 07:53:02 AM CET 2026] Success
[Fri Mar 20 07:53:02 AM CET 2026] Verifying: *.test2.nerdoza.studio
[Fri Mar 20 07:53:02 AM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Fri Mar 20 07:53:05 AM CET 2026] Success
[Fri Mar 20 07:53:05 AM CET 2026] Removing DNS records.
[Fri Mar 20 07:53:05 AM CET 2026] Removing txt: ORgxxxxx for domain: test2.medynski.ovh
[Fri Mar 20 07:53:05 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:53:05 AM CET 2026] Checking authentication
[Fri Mar 20 07:53:06 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:53:08 AM CET 2026] Successfully removed
[Fri Mar 20 07:53:08 AM CET 2026] Removing txt: 3Dgxxxxx for domain: test2.medynski.ovh
[Fri Mar 20 07:53:08 AM CET 2026] Using OVH endpoint: ovh-eu
[Fri Mar 20 07:53:08 AM CET 2026] Checking authentication
[Fri Mar 20 07:53:08 AM CET 2026] Consumer key is ok.
[Fri Mar 20 07:53:10 AM CET 2026] Successfully removed
[Fri Mar 20 07:53:10 AM CET 2026] Verification finished, beginning signing.
[Fri Mar 20 07:53:10 AM CET 2026] Let's finalize the order.
[Fri Mar 20 07:53:10 AM CET 2026] Le_OrderFinalize='https://acme.zerossl.com/v2/DV90/order/xxxxx/finalize'
[Fri Mar 20 07:53:10 AM CET 2026] Order status is 'processing', let's sleep and retry.
[Fri Mar 20 07:53:10 AM CET 2026] Sleeping for 15 seconds then retrying
[Fri Mar 20 07:53:26 AM CET 2026] Polling order status: https://acme.zerossl.com/v2/DV90/order/xxxxx
[Fri Mar 20 07:53:27 AM CET 2026] Downloading cert.
[Fri Mar 20 07:53:27 AM CET 2026] Le_LinkCert='https://acme.zerossl.com/v2/DV90/cert/xxxxx'
[Fri Mar 20 07:53:27 AM CET 2026] Cert success.
-----BEGIN CERTIFICATE-----
MIIEG...
-----END CERTIFICATE-----
[Fri Mar 20 07:53:27 AM CET 2026] Your cert is in: /home/user/.acme.sh/test2.nerdoza.studio_ecc/test2.nerdoza.studio.cer
[Fri Mar 20 07:53:27 AM CET 2026] Your cert key is in: /home/user/.acme.sh/test2.nerdoza.studio_ecc/test2.nerdoza.studio.key
[Fri Mar 20 07:53:27 AM CET 2026] The intermediate CA cert is in: /home/user/.acme.sh/test2.nerdoza.studio_ecc/ca.cer
[Fri Mar 20 07:53:27 AM CET 2026] And the full-chain cert is in: /home/user/.acme.sh/test2.nerdoza.studio_ecc/fullchain.cer

Prywatne PKI z wykorzystaniem HashiCorp Vault

Do przetestowania pozostał jedynie temat prywatnego PKI z ACME. Skorzystałem z HashiCorp Vault do zbudowania własnego Certificate Authority.

Zestawienie środowiska testowego jest całkiem dobrze opisane na stronie projektu. Użyłem instalacji w dockerze.

Na stronie HashiCorp istnieje gotowy tutorial dla PKI ACME.

  • uruchomienie serwera

    docker run -it --rm --cap-add=IPC_LOCK \
      -e 'VAULT_LOCAL_CONFIG={"storage": {"file": {"path": "/vault/file"}}, "listener": [{"tcp": { "address": "0.0.0.0:8201","tls_disable": true}}], "default_lease_ttl": "168h", "max_lease_ttl": "720h", "ui": true}' \
      -p 8201:8201 hashicorp/vault server
    
  • konfiguracja root CA (mount: pki/)

    Vault Root CA
    Vault Root CA

  • ustawienie roli (swoją nazwałem: medynski-dot-ovh)

    Vault IntCA Role
    Vault IntCA Role

Następnie trzeba skonfigurować ACME dla pki_int/. Kroki opisane są w dokumentacji Vault. Wszystkie opcje wyklikałem zgodnie z instrukcją z GUI, poza ustawieniem nagłówków. Te ustawiłem bezpośrednio w kontenerze vault.

Podczas konfiguracji ACME musiałem podać Cluster Endpoint.

# vault CLI
> vault write pki_int/config/acme enabled=true

Error writing to: pki_int/config/acme.
URL: /v1/pki_int/config/acme
Code: 500
Errors:
  1 error occurred:
    * ACME feature requires local cluster 'path' field configuration to be set
Vault pki configuration
Vault pki configuration
# switch to container shell
docker exec -it vault-acme /bin/sh

# set vault addr (plain HTTP)
export VAULT_ADDR=http://127.0.0.1:8201

# login
vault login -no-print

# tune pki_int
vault secrets tune \
  -allowed-response-headers=Location \
  -allowed-response-headers=Replay-Nonce \
  -allowed-response-headers=Link \
  pki_int/

Bez ustawienia nagłówków acme.sh protestował poniższym błędem.

[Sat Mar 21 10:36:25 AM CET 2026] Using CA: http://172.16.16.21:8201/v1/pki_int/acme/directory
[Sat Mar 21 10:36:25 AM CET 2026] Account key creation OK.
[Sat Mar 21 10:36:25 AM CET 2026] Registering account: http://172.16.16.21:8201/v1/pki_int/acme/directory
[Sat Mar 21 10:36:25 AM CET 2026] Please refer to https://curl.haxx.se/libcurl/c/libcurl-errors.html for error code: 7
[Sat Mar 21 10:36:25 AM CET 2026] Please refer to https://curl.haxx.se/libcurl/c/libcurl-errors.html for error code: 7
[Sat Mar 21 10:36:25 AM CET 2026] Could not get nonce, let's try again.

Publiczny dostęp

Żeby skorzystać z prywatnego wystawcy certów, wystarczy podać dodatkowy parametr --server.

# sleep added to manually check DNS zone in OVH admin panel
# @see https://github.com/acmesh-official/acme.sh/wiki/Options-and-Params
# @see https://github.com/acmesh-official/acme.sh/wiki/Server
acme.sh --issue --dns dns_ovh --dnssleep 60 -d vault-test.medynski.ovh --server http://172.16.16.21:8201/v1/pki_int/acme/directory --force

[Sat Mar 21 10:38:38 AM CET 2026] Using CA: http://172.16.16.21:8201/v1/pki_int/acme/directory
[Sat Mar 21 10:38:38 AM CET 2026] Registering account: http://172.16.16.21:8201/v1/pki_int/acme/directory
[Sat Mar 21 10:38:39 AM CET 2026] Registered
[Sat Mar 21 10:38:39 AM CET 2026] ACCOUNT_THUMBPRINT='g7iP8xxxxx'
[Sat Mar 21 10:38:39 AM CET 2026] Creating domain key
[Sat Mar 21 10:38:39 AM CET 2026] The domain key is here: /home/user/.acme.sh/vault-test.medynski.ovh_ecc/vault-test.medynski.ovh.key
[Sat Mar 21 10:38:39 AM CET 2026] Single domain='vault-test.medynski.ovh'
[Sat Mar 21 10:38:39 AM CET 2026] Getting webroot for domain='vault-test.medynski.ovh'
[Sat Mar 21 10:38:39 AM CET 2026] Adding TXT value: KbMxxxxx for domain: _acme-challenge.vault-test.medynski.ovh
[Sat Mar 21 10:38:39 AM CET 2026] Using OVH endpoint: ovh-eu
[Sat Mar 21 10:38:39 AM CET 2026] Checking authentication
[Sat Mar 21 10:38:40 AM CET 2026] Consumer key is ok.
[Sat Mar 21 10:38:41 AM CET 2026] Adding record
[Sat Mar 21 10:38:41 AM CET 2026] Added, sleep 10 seconds.
[Sat Mar 21 10:38:52 AM CET 2026] The TXT record has been successfully added.
[Sat Mar 21 10:38:52 AM CET 2026] Sleeping for 60 seconds to wait for the the TXT records to take effect
[Sat Mar 21 10:39:54 AM CET 2026] Verifying: vault-test.medynski.ovh
[Sat Mar 21 10:39:54 AM CET 2026] Processing. The CA is processing your order, please wait. (1/30)
[Sat Mar 21 10:39:57 AM CET 2026] Success
[Sat Mar 21 10:39:57 AM CET 2026] Removing DNS records.
[Sat Mar 21 10:39:57 AM CET 2026] Removing txt: KbMxxxxx for domain: _acme-challenge.vault-test.medynski.ovh
[Sat Mar 21 10:39:57 AM CET 2026] Using OVH endpoint: ovh-eu
[Sat Mar 21 10:39:57 AM CET 2026] Checking authentication
[Sat Mar 21 10:39:57 AM CET 2026] Consumer key is ok.
[Sat Mar 21 10:40:00 AM CET 2026] Successfully removed
[Sat Mar 21 10:40:00 AM CET 2026] Verification finished, beginning signing.
[Sat Mar 21 10:40:00 AM CET 2026] Let's finalize the order.
[Sat Mar 21 10:40:00 AM CET 2026] Le_OrderFinalize='http://172.16.16.21:8201/v1/pki_int/acme/order/ff1115b1-xxxxx/finalize'
[Sat Mar 21 10:40:00 AM CET 2026] Downloading cert.
[Sat Mar 21 10:40:00 AM CET 2026] Le_LinkCert='http://172.16.16.21:8201/v1/pki_int/acme/order/ff1115b1-xxxxx/cert'
[Sat Mar 21 10:40:00 AM CET 2026] Cert success.
-----BEGIN CERTIFICATE-----
MIIEL...
-----END CERTIFICATE-----
[Sat Mar 21 10:40:00 AM CET 2026] Your cert is in: /home/user/.acme.sh/vault-test.medynski.ovh_ecc/vault-test.medynski.ovh.cer
[Sat Mar 21 10:40:00 AM CET 2026] Your cert key is in: /home/user/.acme.sh/vault-test.medynski.ovh_ecc/vault-test.medynski.ovh.key
[Sat Mar 21 10:40:00 AM CET 2026] The intermediate CA cert is in: /home/user/.acme.sh/vault-test.medynski.ovh_ecc/ca.cer
[Sat Mar 21 10:40:00 AM CET 2026] And the full-chain cert is in: /home/user/.acme.sh/vault-test.medynski.ovh_ecc/fullchain.cer

Widać, że wszystko działa, jak należy. W OVHcloud pojawił się wpis TXT.

OVHcloud DNS
OVHcloud DNS

Wystawiony certyfikat jest prawidłowy. Wpis pojawił się w Vaulcie.

Vault cert
Vault cert
Vault cert
Vault cert
Vault cert
Vault cert

Wymuszenie EAB

Zmieniłem politykę EAB dla ACME pki_int. Od teraz nie można wystawić certyfikatu bez "logowania się" użytkownika.

Vault EAB Policy
Vault EAB Policy

Próba wygenerowania certyfikatu kończy się teraz błędem.

[Sat Mar 21 03:11:48 PM CET 2026] Using CA: http://172.16.16.21:8201/v1/pki_int/acme/directory
[Sat Mar 21 03:11:48 PM CET 2026] Single domain='vault-test-47.medynski.ovh'
[Sat Mar 21 03:11:48 PM CET 2026] Error creating new order. Le_OrderFinalize not found. {"type":"urn:ietf:params:acme:error:externalAccountRequired","detail":"The request must include a value for the 'externalAccountBinding' field"}
[Sat Mar 21 03:11:48 PM CET 2026] Please check log file for more details: /home/user/.acme.sh/acme.sh.log

Należy zarejestrować nowe konto podając EAB. Po stronie vault EAB wygenerowałem w konsoli GUI.

Vault EAB Create
Vault EAB Create
# set EAB
export EAB_KEY="d1745201-xxxxx"          # vault 'id' field
export EAB_HMAC_KEY="vault-eab-0-xxxxx"  # vault 'key' field

# remove old registration
rm -rf ~/.acme.sh/ca/172.16.16.21/v1/pki_int/acme/directory

# register account
# @see https://github.com/acmesh-official/acme.sh/wiki/Options-and-Params
acme.sh --register-account \
  --server http://172.16.16.21:8201/v1/pki_int/acme/directory \
  --eab-kid "$EAB_KEY" \
  --eab-hmac-key "$EAB_HMAC_KEY"

Generowanie certyfikatów ponownie działa.

acme.sh --issue --dns dns_ovh -d vault-test-47.medynski.ovh --server http://172.16.16.21:8201/v1/pki_int/acme/directory --force

[Sat Mar 21 03:33:25 PM CET 2026] Using CA: http://172.16.16.21:8201/v1/pki_int/acme/directory
[Sat Mar 21 03:33:25 PM CET 2026] Single domain='vault-test-47.medynski.ovh'
[Sat Mar 21 03:33:25 PM CET 2026] Getting webroot for domain='vault-test-47.medynski.ovh'
[Sat Mar 21 03:33:26 PM CET 2026] Adding TXT value: iFsq6Ubxxxxx for domain: _acme-challenge.vault-test-47.medynski.ovh
[Sat Mar 21 03:33:26 PM CET 2026] Using OVH endpoint: ovh-eu
[Sat Mar 21 03:33:26 PM CET 2026] Checking authentication
[Sat Mar 21 03:33:26 PM CET 2026] Consumer key is ok.
[Sat Mar 21 03:33:27 PM CET 2026] Adding record
[Sat Mar 21 03:33:28 PM CET 2026] Added, sleep 10 seconds.
[Sat Mar 21 03:33:39 PM CET 2026] The TXT record has been successfully added.
[Sat Mar 21 03:33:39 PM CET 2026] Let's check each DNS record now. Sleeping for 20 seconds first.
[Sat Mar 21 03:34:00 PM CET 2026] You can use '--dnssleep' to disable public dns checks.
[Sat Mar 21 03:34:00 PM CET 2026] See: https://github.com/acmesh-official/acme.sh/wiki/dnscheck
[Sat Mar 21 03:34:00 PM CET 2026] Checking vault-test-47.medynski.ovh for _acme-challenge.vault-test-47.medynski.ovh
[Sat Mar 21 03:34:00 PM CET 2026] Success for domain vault-test-47.medynski.ovh '_acme-challenge.vault-test-47.medynski.ovh'.
[Sat Mar 21 03:34:00 PM CET 2026] All checks succeeded
(...)

Lokalny DNS

TODO

Zrobić test z lokalnym DNS'em opartym na PowerDNS. acme.sh wspiera PowerDNS API. HashiCorp Vault wspiera custom resolver.

sequenceDiagram autonumber participant Cron as Harmonogram (Cron) participant Acme as acme.sh (Klient) participant Vault as HashiCorp Vault (PKI / ACME) participant PDNS as PowerDNS (API) Cron->>Acme: Uruchomienie procesu odświeżania Acme->>Vault: Żądanie wydania certyfikatu (Certificate Signing Request) Vault-->>Acme: Zwrócenie wyzwania DNS-01 (token dla rekordu TXT) Note over Acme, PDNS: Faza autoryzacji domeny Acme->>PDNS: Utworzenie rekordu TXT (_acme-challenge.domena.pl) przez REST API PDNS-->>Acme: Potwierdzenie dodania rekordu Acme->>Vault: Informacja o gotowości wyzwania do weryfikacji Vault->>PDNS: Odpytanie serwera DNS o rekord TXT dla _acme-challenge.domena.pl PDNS-->>Vault: Zwrócenie wartości rekordu TXT Note over Acme, Vault: Faza wydania certyfikatu Vault-->>Acme: Weryfikacja poprawna. Wydanie podpisanego certyfikatu Note over Acme, PDNS: Sprzątanie i wdrożenie Acme->>PDNS: Usunięcie rekordu TXT (_acme-challenge.domena.pl) przez API Acme->>Acme: Zapisanie certyfikatów na dysku Acme->>Acme: Wykonanie skryptu przeładowania usługi (np. Nginx/Apache reload)

Automatyzacja

TODO

Zaprojektować automat zarządzany przez Ansible / Jenkins.

Linki