티스토리 뷰

Infra

certbot wildcard 인증서 갱신

꼬꼬팜 2025. 6. 14. 22:39

ACME dns-01

Let’s Encrypt는 도메인 소유권을 검증하기 위해 여러 인증 방식을 지원
그중 dns-01 challenge는 다음 절차를 따름

 

  1. 인증 서버가 무작위 토큰을 발급함
  2. Certbot은 토큰과 계정 키를 조합해 CERTBOT_VALIDATION 값을 생성
  3. 도메인의 _acme-challenge.example.com이라는 위치에 TXT 레코드를 등록
  4. Let’s Encrypt 서버는 등록된 TXT 레코드를 DNS 쿼리를 통해 직접 조회하여, 해당 토큰 값이 정확히 존재하는지 확인
  5. 성공 시 도메인 소유가 인증되고 인증서 발급이 진행

“Wildcard certificates can only be requested via DNS challenge.”
Let’s Encrypt FAQ

문제 상황

이 방식은 도메인마다 고유한 CERTBOT_VALIDATION 값을 요구하지만,

*.example.comexample.com의 dns-01 challenge는 둘 다 동일한 TXT 레코드 이름을 사용

  • Certbot이 첫 번째 도메인 *.example.com을 인증하기 위해 TXT 레코드를 설정한 후,
  • 두 번째 도메인 example.com을 인증하면서 같은 위치의 TXT 레코드를 덮어쓰게 됨
  • 이로 인해 첫 번째 도메인의 인증값이 사라져 실패하게 되었음

기존 스크립트

#!/bin/bash
# 현재 디렉토리를 workdir로 설정
workdir="$PWD"
echo "Working directory: $workdir"


# Certbot 갱신
echo "Renewing certificates..."

certbot certonly --non-interactive --quiet --manual \
--preferred-challenges dns \
--manual-auth-hook "$workdir/godaddy-dns-update.py" \
--manual-cleanup-hook 'rm -f /tmp/CERTBOT_VALIDATION' \
-d *.example.com -d example.com
Script started at: 2025-06-14 12:30:58
Working directory: /root/dns-update
Renewing certificates...
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Renewing an existing certificate for *.example.com and example.com
Encountered exception during recovery: KeyError: KeyAuthorizationAnnotatedChallenge(challb=ChallengeBody(chall=DNS01(token=b"..."), uri='https://acme-v02.api.letsencrypt.org/acme/chall/2315054967/536008867492/ZmF4mA', status=Status(pending), validated=None, error=None), domain='example.com', account_key=JWKRSA(key=<ComparableRSAKey(<cryptography.hazmat.backends.openssl.rsa._RSAPrivateKey object at 0x7d750c42d2e0>)>))
Exiting due to user request.

제약 조건

  • Certbot의 공식 DNS 플러그인을 사용할 수 없는 환경이며, DNS는 GoDaddy를 통해 관리되고 있었기 때문에 manual-auth-hookmanual-cleanup-hook을 직접 구현
  • 인증서에는 반드시 다음 두 도메인을 포함
    • *.example.com
    • example.com
  • 두 도메인은 모두 _acme-challenge.example.com 위치에 TXT 레코드를 작성
  • 각각의 도메인 인증에 대해 다른 CERTBOT_VALIDATION 값이 주어졌으며, 이 값들을 동시에 DNS에 유지하지 않으면 인증 실패 발생
  • 인증서 파일 경로는 기존 시스템과의 연동으로 인해 /etc/letsencrypt/live/example.com/으로 고정되어야 했고, 도메인별 인증서를 분리할 수 없는 상황

해결

기존 manual-auth-hook에서 TXT 레코드를 새로 덮어쓰는 방식 대신, 기존 값을 조회한 뒤 새롭게 받은 값을 함께 등록하도록 변경함. 이를 통해 *.example.comexample.com의 인증 값이 동시에 존재하도록 보장

#!/bin/bash

# 현재 디렉토리를 workdir로 설정 (절대 경로)
workdir="$(pwd)"
echo "Working directory: $workdir"

# Certbot 갱신
echo "Renewing certificates..."
certbot certonly \
  --manual \
  --preferred-challenges dns \
  --manual-auth-hook "$workdir/godaddy-dns-update.py" \
  --manual-cleanup-hook "$workdir/godaddy-dns-cleanup.py" \
  --non-interactive \
  --force-renewal \
  -d '*.example.com' -d example.com

# 종료 시각 출력
echo "Script ended at: $(date '+%Y-%m-%d %H:%M:%S')"
  • hook 위치를 절대 경로로 지정해야 renew 시에 해당 위치를 찾음

dns record update

#!/usr/bin/env python3
import os
import requests
import time
import dns.resolver
from dotenv import load_dotenv

load_dotenv()

api_key = os.getenv('GODADDY_API_KEY')
api_secret = os.getenv('GODADDY_API_SECRET')
certbot_domain = os.getenv('CERTBOT_DOMAIN')
validation_value = os.getenv('CERTBOT_VALIDATION')
zone_domain = "example.com"

if not all([api_key, api_secret, certbot_domain, validation_value]):
    print("Missing required environment variables.")
    exit(1)

def get_record_name(certbot_domain, zone_domain):
    if certbot_domain == zone_domain:
        return "_acme-challenge"
    elif certbot_domain.endswith("." + zone_domain):
        sub = certbot_domain[:-(len(zone_domain) + 1)]
        return f"_acme-challenge.{sub}"
    else:
        raise ValueError("CERTBOT_DOMAIN doesn't match ZONE_DOMAIN")

record_name = get_record_name(certbot_domain, zone_domain)
fqdn = f"{record_name}.{zone_domain}"
record_type = "TXT"
record_ttl = 600

url = f"https://api.godaddy.com/v1/domains/{zone_domain}/records/{record_type}/{record_name}"
headers = {
    "Authorization": f"sso-key {api_key}:{api_secret}",
    "Content-Type": "application/json"
}

# 1. 기존 값 조회
existing_resp = requests.get(url, headers=headers)
existing_values = []
if existing_resp.status_code == 200:
    existing_values = [r["data"] for r in existing_resp.json()]
else:
    print("Warning: failed to read existing TXT values")

# 2. 현재 값 추가
if validation_value not in existing_values:
    existing_values.append(validation_value)

# 3. 등록
payload = [{"data": val, "ttl": record_ttl} for val in existing_values]
put_resp = requests.put(url, headers=headers, json=payload)
if put_resp.status_code != 200:
    print("Failed to update TXT record")
    print(put_resp.text)
    exit(1)

print(f"TXT record set for {fqdn} → {validation_value}")
print("Waiting for DNS to propagate...")

# 4. 전파 확인
resolver = dns.resolver.Resolver()
resolver.cache = None
resolver.nameservers = ['8.8.8.8', '1.1.1.1']

MAX_WAIT = 180
INTERVAL = 10
elapsed = 0
while elapsed < MAX_WAIT:
    try:
        answers = resolver.resolve(fqdn, 'TXT')
        for rdata in answers:
            txt = rdata.to_text().strip('"')
            if txt == validation_value:
                print(f"TXT record verified in DNS after {elapsed} seconds.")
                exit(0)
    except Exception:
        pass
    time.sleep(INTERVAL)
    elapsed += INTERVAL
    print(f"Still waiting... {elapsed}s")

print("Timeout: DNS record not visible after propagation wait.")
exit(1)
  • 기존 레코드를 가져와서 새 CERTBOT_VALIDATION 값을 중복 없이 추가
  • 갱신 중인 도메인이 2개 이상이라도 레코드가 동시에 유효함
  • wait_for_propagation() 함수에서 10초 간격, 최대 180초 대기하며 propagation 확인
# godaddy-dns-cleanup.py
#!/usr/bin/env python3
import os
import requests
from dotenv import load_dotenv

load_dotenv()

api_key = os.getenv('GODADDY_API_KEY')
api_secret = os.getenv('GODADDY_API_SECRET')
certbot_domain = os.getenv('CERTBOT_DOMAIN')
validation_value = os.getenv('CERTBOT_VALIDATION')
zone_domain = "example.com"

if not all([api_key, api_secret, certbot_domain, validation_value]):
    print("Missing environment variables")
    exit(1)

def get_record_name(certbot_domain, zone_domain):
    if certbot_domain == zone_domain:
        return "_acme-challenge"
    elif certbot_domain.endswith("." + zone_domain):
        sub = certbot_domain[:-(len(zone_domain) + 1)]
        return f"_acme-challenge.{sub}"
    else:
        raise ValueError("CERTBOT_DOMAIN doesn't match ZONE_DOMAIN")

record_name = get_record_name(certbot_domain, zone_domain)
url = f"https://api.godaddy.com/v1/domains/{zone_domain}/records/TXT/{record_name}"
headers = {
    "Authorization": f"sso-key {api_key}:{api_secret}",
    "Content-Type": "application/json"
}

# 기존 값 조회
get_resp = requests.get(url, headers=headers)
if get_resp.status_code != 200:
    print("Failed to fetch existing TXT records during cleanup.")
    exit(0)  # 오류로 종료하면 인증 실패 처리됨, 여기선 무시

existing_values = [r["data"] for r in get_resp.json()]
remaining_values = [val for val in existing_values if val != validation_value]

# TXT 레코드 갱신
payload = [{"data": val, "ttl": 600} for val in remaining_values]
put_resp = requests.put(url, headers=headers, json=payload)
if put_resp.status_code != 200:
    print("Failed to cleanup TXT record")
    print(put_resp.text)
  • 인증서가 발급된 이후, 인증에 사용했던 txt record를 제외하고 레코드 덮어쓰기
~/dns-update# ./update_certificate.sh
Script started at: 2025-06-14 12:32:24
Renewing certificates...
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Renewing an existing certificate for *.example.com and example.com
Hook '--manual-auth-hook' for example.com ran with output:
 TXT record set for _acme-challenge.example.com → DCsNlhX36mwzUrTPPFHGieT4P3uitA68kN9uYoIgNWg
 Waiting for DNS to propagate...
 Still waiting... 10s
 Still waiting... 20s
 Still waiting... 30s
 Still waiting... 40s
 TXT record verified in DNS after 40 seconds.

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/example.com/privkey.pem
This certificate expires on 2025-09-12.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
If you like Certbot, please consider supporting our work by:
 * Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
 * Donating to EFF:                    https://eff.org/donate-le
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Script ended at: 2025-06-14 12:33:15
  • 인증서 발급 이후 자동 renew.conf 생성
# /etc/letsencrypt/renewal/example.com.conf
# renew_before_expiry = 30 days
version = 2.9.0
archive_dir = /etc/letsencrypt/archive/example.com
cert = /etc/letsencrypt/live/example.com/cert.pem
privkey = /etc/letsencrypt/live/example.com/privkey.pem
chain = /etc/letsencrypt/live/example.com/chain.pem
fullchain = /etc/letsencrypt/live/example.com/fullchain.pem

# Options used in the renewal process
[renewalparams]
account = {acount}
pref_challs = dns-01,
authenticator = manual
server = https://acme-v02.api.letsencrypt.org/directory
key_type = ecdsa
manual_auth_hook = /{path}/godaddy-dns-update.py
manual_cleanup_hook = /{path}/godaddy-dns-cleanup.py

 

 

source code repo

 

GitHub - CSPCLAB/dns-update: SSL certificates 자동화 관리

SSL certificates 자동화 관리. Contribute to CSPCLAB/dns-update development by creating an account on GitHub.

github.com

 

'Infra' 카테고리의 다른 글

[k8s] Core Components  (0) 2025.08.24
[OpenStack] Cinder service set  (0) 2025.08.09
KVM  (0) 2025.06.10
Cloud Block Storage  (0) 2025.06.03
Ext4 vs XFS  (0) 2025.06.01
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
글 보관함