Luka, na którą wciąż się natykałem#
Wystawianie i odnawianie certyfikatów TLS to problem rozwiązany — dopóki nie umieścisz go w trochę nietypowym miejscu. Mnie zależało na takiej kombinacji: certyfikat Let’s Encrypt trafiający bezpośrednio do Azure Key Vault, odnawiany automatycznie, z wyzwaniem ACME HTTP-01 obsługiwanym z wnętrza środowiska Azure Container Apps.
Brzmi przyziemnie, ale kiedy zacząłem szukać czegoś, co to robi, wróciłem z pustymi rękami. Typowe narzędzia ACME zakładają jeden z kilku światów:
- cert-manager — świetny, ale żyje w Kubernetes i wymaga klastra, CRD-ów oraz wzorca operatora. Container Apps jest serverless; nie ma klastra, do którego można by go zainstalować.
- Caddy / Traefik / nginx + acme.sh — terminują TLS na reverse proxy i trzymają certyfikat na lokalnym dysku. To dokładne przeciwieństwo tego, czego chciałem: certyfikat musi być w Key Vault, żeby Container Apps mogło podpiąć go pod własną domenę.
- certbot i pokrewne — zbudowane wokół maszyny wirtualnej albo hosta z systemem plików i cronem, a nie wokół distrolessowego, nierootowego, efemerycznego kontenera.
Żadne z nich nie umieszcza certyfikatu tam, skąd Azure Container Apps faktycznie go czyta przy zarządzanym TLS dla własnej domeny — czyli w Key Vault. Można skleić pipeline: uruchomić certbota gdzieś, wyeksportować PFX, wepchnąć go do Key Vault skryptem i zaplanować całość — ale to kruchy łańcuch ruchomych elementów do czegoś, co powinno być jednym, długo żyjącym procesem.
Napisałem więc ten jeden proces. Zrobiłem to po godzinach, jako ćwiczenie open source, i opublikowałem na licencji Apache 2.0 pod adresem github.com/emilgruzalski/acme-az-aca. To generyczne narzędzie do generycznego problemu — nie ma w nim niczego związanego z konkretnym wdrożeniem.
Co właściwie robi#
Aplikacja to mały demon w Go, który działa obok Twoich aplikacji w środowisku Container Apps. Dodajesz jedną regułę ingressu, która kieruje ruch /.well-known/acme-challenge/* do niego, a resztą zajmuje się on sam.
W każdym cyklu (domyślnie co 24 godziny):
- Czyta bieżący certyfikat z Azure Key Vault i sprawdza datę wygaśnięcia względem progu odnowienia (domyślnie 30 dni). Jeśli wciąż jest zdrowy, wraca do uśpienia.
- Zamawia certyfikat w Let’s Encrypt przez ACME.
- Odpowiada na wyzwanie HTTP-01 z własnego wbudowanego serwera HTTP — Let’s Encrypt uderza w
http://<domena>/.well-known/acme-challenge/<token>, reguła ingressu kieruje to do tego kontenera, a ten zwraca key authorization. - Konwertuje PEM do PFX w pamięci, bez zależności od OpenSSL.
- Importuje PFX do Key Vault przez Azure SDK. Container Apps podchwytuje go następnie do powiązań z własną domeną.
- Śpi do kolejnego sprawdzenia.
Jeśli coś zawiedzie, a powiadomienia są włączone, wysyła raport błędu przez SMTP i ponawia próbę w następnym cyklu.
Kilka uwag implementacyjnych#
Świadomie utrzymałem małą powierzchnię zależności. Kilka decyzji wartych odnotowania:
Lego do protokołu ACME. Zamiast reimplementować ACME, użyłem go-acme/lego i zaimplementowałem jego interfejs challenge.Provider przy pomocy maleńkiego, trzymanego w pamięci magazynu tokenów, który jest jednocześnie http.Handlerem. Ta sama struktura zapisuje token wyzwania i go serwuje:
func (p *challengeProvider) Present(domain, token, keyAuth string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.tokens[token] = keyAuth
return nil
}
func (p *challengeProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/")
p.mu.RLock()
keyAuth, ok := p.tokens[token]
p.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
w.Write([]byte(keyAuth))
}Żadnego dysku, żadnych plików tymczasowych — odpowiedź na wyzwanie istnieje wyłącznie w pamięci, tylko na czas weryfikacji.
Konwersja PFX bez OpenSSL. Key Vault importuje certyfikaty jako PKCS#12 (PFX). Wywoływanie openssl oznaczałoby grubszy obraz i powłokę w kontenerze, więc konwertuję PEM do PFX w procesie, używając go-pkcs12 z nowoczesnym szyfrowaniem AES-256, z obsługą kluczy RSA i ECDSA.
DefaultAzureCredential do uwierzytelniania. W Container Apps przypisujesz Managed Identity i demon uwierzytelnia się bez żadnych sekretów. Lokalnie, albo gdziekolwiek Managed Identity jest niedostępne, schodzi do Service Principal przez znane zmienne AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET.
Zbudowany pod kontener serverless. To jeden długo żyjący proces z endpointem /healthz, łagodnym zamknięciem na SIGINT/SIGTERM, strukturalnym logowaniem przez log/slog, dostarczany jako obraz distroless ~20 MB działający jako użytkownik nieroot. Taki kształt lubi Container Apps — a nie kształt „maszyna wirtualna i cron”.
Dlaczego open source#
Problem nie jest tylko mój — każdy, kto uruchamia Azure Container Apps z własną domeną i chce darmowego, automatycznie odnawianego TLS, uderza w tę samą ścianę. Narzędzia nie było, więc rozsądnie było napisać czyste, generyczne i je rozdać, zamiast wciąż rozwiązywać to samo prywatnie. To samodzielny projekt poboczny: jeden binarny plik, klarowny README, licencja Apache 2.0 i CI na GitHubie.
Jeśli jesteś w tym samym miejscu, kod, diagram architektury i kroki wdrożenia znajdziesz tutaj: github.com/emilgruzalski/acme-az-aca.