Przewiń do głównej treści

Klient ACME dla Azure Container Apps

·714 słów·4 min
Autor
Emil Grużalski
Infrastruktura & Automatyzacja Chmury

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):

  1. 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.
  2. Zamawia certyfikat w Let’s Encrypt przez ACME.
  3. 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.
  4. Konwertuje PEM do PFX w pamięci, bez zależności od OpenSSL.
  5. Importuje PFX do Key Vault przez Azure SDK. Container Apps podchwytuje go następnie do powiązań z własną domeną.
  6. Ś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.