win-acme(ACMEv2 클라이언트)를 사용하여 Certificate Manager ACME 기능으로 TLS 인증서를 자동으로 발급·갱신하는 방법을 설명합니다. DNS-01 챌린지 검증에 Ncloud Global DNS API를 사용하며, Windows Task Scheduler로 자동 갱신을 구성합니다.
Certificate Manager ACME는 RSA 2048 키만 허용합니다. win-acme의 기본 키 크기는 RSA 3072이므로, 설정을 변경하지 않으면 DNS-01 검증에 성공하더라도 인증서 발급이 실패합니다. 반드시 settings.json에서 키 크기를 2048로 변경하십시오.
서비스 사양
| 항목 | 내용 |
|---|---|
| 지원 OS | Windows Server 2019, Windows Server 2022 |
| 권장 클라이언트 | win-acme v2.2.9 이상 (standalone, 64-bit) |
| 검증 방식 | DNS-01 (NCP Global DNS API) |
| 키 알고리즘 | RSA 2048 / SHA256withRSA (필수) |
| 인증서 저장소 | Windows Certificate Store — WebHosting |
| ACME 엔드포인트 | https://acme.navercloudtrust.com/acme/directory |
시작하기 전에
이 가이드를 진행하기 전에 다음 항목이 준비되어 있어야 합니다.
| 항목 | 확인 내용 |
|---|---|
| NCP IAM API 키 | Access Key/Secret Key 발급 완료, Global DNS 권한 부여 확인 |
| EAB 키 | Certificate Manager 콘솔에서 EAB Key ID/HMAC Key 발급 완료 |
| Ncloud Global DNS | 인증서를 발급할 도메인이 Global DNS에 존으로 등록되어 있음 |
| 관리자 권한 | wacs.exe 실행 및 Windows 인증서 저장소 접근을 위한 로컬 관리자 권한 |
| IIS | HTTPS 인증서를 적용할 사이트 구성 완료 |
| PowerShell 실행 정책 | RemoteSigned 이상 설정 확인 |
EAB(External Account Binding) 키는 ACME 계정 등록 시 1회만 사용됩니다. 한 번 등록된 계정은 이후 갱신에 자동 재사용되므로 키를 재발급할 필요가 없습니다.
1단계: win-acme 설치 및 설정
win-acme 설치
- win-acme 공식 릴리스 페이지에서 v2.2.9 이상의 release, trimmed, standalone, 64-bit 버전을 다운로드합니다.
- 원하는 경로에 압축을 해제합니다.
settings.json 수정
win-acme 설치 디렉터리의 settings.json 파일을 열어 아래 두 항목을 반드시 수정합니다.
1. ACME 엔드포인트 변경
Acme 섹션의 DefaultBaseUri 값을 아래와 같이 변경합니다. win-acme 기본값은 Let's Encrypt이므로 반드시 변경해야 합니다.
"Acme": {
"DefaultBaseUri": "https://acme.navercloudtrust.com/acme/directory"
}
2. RSA 키 크기 변경 (필수)
Csr 섹션을 찾아 아래와 같이 수정합니다.
"Csr": {
"Rsa": {
"KeyBits": 2048,
"SignatureAlgorithm": "SHA256withRSA"
}
}
KeyBits 기본값은 3072입니다. 이 값을 변경하지 않으면 DNS-01 검증이 성공하더라도 CA가 order를 invalid 처리하며 인증서 발급을 거부합니다.
설정 완료 후 핵심 항목은 다음과 같습니다.
| 키 경로 | 설정값 |
|---|---|
Acme.DefaultBaseUri |
https://acme.navercloudtrust.com/acme/directory |
Csr.Rsa.KeyBits |
2048 |
Csr.Rsa.SignatureAlgorithm |
SHA256withRSA |
ScheduledTask.RenewalDays |
45 (인증서 유효기간 198일 일 때 예시, 만료 시점 1/3 전부터 갱신 시도 권장) |
2단계: DNS 훅 스크립트 준비
win-acme는 DNS-01 챌린지 수행 시 사용자가 준비한 외부 스크립트를 호출하여 DNS TXT 레코드를 생성·삭제합니다. DNS 훅 스크립트는 별도로 제공되지 않으므로 직접 작성해야 합니다. Ncloud Global DNS API를 사용하는 예시는 부록을 참조해 주십시오.
훅 스크립트 요구 사항
win-acme는 DNS-01 챌린지 시 아래 두 스크립트를 순서대로 호출합니다. 각 스크립트는 다음 역할을 수행해야 합니다.
생성 스크립트 (dns-create)
win-acme가 챌린지를 시작할 때 호출됩니다. 다음을 처리해야 합니다.
- 전달받은 도메인에 해당하는 DNS zone을 탐색합니다.
_acme-challenge.<도메인>TXT 레코드를 생성합니다.- DNS 변경 사항을 반영합니다.
- DNS 전파가 완료될 때까지 충분히 대기합니다. (권장: 60초 이상)
삭제 스크립트 (dns-delete)
챌린지 검증이 완료된 후 호출됩니다. 다음을 처리해야 합니다.
- 생성 스크립트에서 만든 TXT 레코드를 삭제합니다.
- DNS 변경 사항을 반영합니다.
스크립트 호출 인터페이스
win-acme는 스크립트를 호출할 때 아래 인수를 순서대로 전달합니다. 스크립트 작성 시 이 형식을 따라야 합니다.
| 인수 순서 | 값 | 설명 |
|---|---|---|
| 1 | create / delete |
수행할 동작 |
| 2 | 도메인명 | 인증서를 발급할 도메인 (예: example.com) |
| 3 | 레코드명 | TXT 레코드 전체 이름 (예: _acme-challenge.example.com) |
| 4 | 토큰 값 | CA가 발행한 챌린지 토큰 (TXT 레코드 값으로 설정) |
NCP Global DNS API 연동 시 유의 사항
Ncloud Global DNS API를 사용하는 경우 아래 사항을 고려하여 스크립트를 작성하십시오.
- 모든 API 요청에는 HMAC-SHA256 서명 헤더가 필요합니다. 아래 세 헤더를 포함해야 합니다.
| 헤더 | 값 |
|---|---|
x-ncp-apigw-timestamp |
요청 시각 (Unix 밀리초) |
x-ncp-iam-access-key |
NCP IAM Access Key |
x-ncp-apigw-signature-v2 |
HMAC-SHA256 서명값 (Base64 인코딩) |
- 레코드 생성·삭제 후에는 반드시 Apply API를 호출해야 DNS에 반영됩니다.
- 와일드카드 인증서(
*.example.com) 발급 시 도메인 앞의*.를 제거하고 zone을 탐색해야 합니다. - 서브도메인 인증서(
sub.example.com) 발급 시_acme-challenge.sub형태로 호스트명을 계산해야 합니다.
스크립트 파일은 반드시 UTF-8 BOM 인코딩으로 저장하십시오. ANSI(CP949) 인코딩으로 저장하면 PowerShell 실행 시 한글이 깨지고 일부 환경에서 오동작합니다.
3단계: 인증서 발급
스크립트 준비가 완료되면 관리자 권한 PowerShell에서 아래 명령을 실행합니다. 대괄호([ ]) 항목은 실제 값으로 교체하십시오.
wacs.exe `
--source manual `
--host "[인증서를 발급할 도메인]" `
--validation dnsscript `
--dnscreatescript "[생성 스크립트 경로]" `
--dnsdeletescript "[삭제 스크립트 경로]" `
--store certificatestore `
--certificatestore WebHosting `
--installation iis `
--siteid [IIS 사이트 ID] `
--eab-key-identifier "[EAB_KEY_ID]" `
--eab-key "[EAB_HMAC_KEY]" `
--emailaddress "[담당자 이메일]" `
--accepttos `
--baseuri "https://acme.navercloudtrust.com/acme/directory"
주요 파라미터 설명
| 파라미터 | 설명 |
|---|---|
--source manual |
도메인을 직접 지정 |
--host |
인증서를 발급할 도메인 |
--validation dnsscript |
스크립트 기반 DNS-01 검증 사용 |
--dnscreatescript |
TXT 레코드 생성 스크립트 경로 |
--dnsdeletescript |
TXT 레코드 삭제 스크립트 경로 |
--certificatestore WebHosting |
인증서를 WebHosting 스토어에 저장 |
--installation iis / --siteid |
IIS 사이트에 인증서 자동 바인딩 |
--eab-key-identifier |
EAB Key ID |
--eab-key |
EAB HMAC Key |
--accepttos |
ACME 약관 자동 동의 |
--baseuri |
ACME 엔드포인트 |
발급 흐름
| 단계 | 내용 |
|---|---|
| 1 | ACME CA 연결 확인 |
| 2 | EAB 키로 ACME 계정 등록 |
| 3 | 인증서 order 생성, DNS-01 챌린지 토큰 수신 |
| 4 | 생성 스크립트 호출 → TXT 레코드 생성 → DNS 전파 대기 |
| 5 | CA가 TXT 레코드 확인 → 검증 완료 |
| 6 | 삭제 스크립트 호출 → TXT 레코드 삭제 |
| 7 | RSA 2048 CSR 제출 → CA 서명 |
| 8 | 인증서 다운로드 → WebHosting 스토어 저장 |
| 9 | IIS 사이트 443 바인딩 자동 교체 |
| 10 | renewal.json 저장 (이후 자동 갱신에 사용) |
문제가 발생하는 경우 명령어에 --verbose 옵션을 추가하면 CA와의 HTTP 요청·응답을 포함한 상세 로그가 출력됩니다. 최초 등록 또는 EAB 교체 후 재등록 시 사용을 권장합니다.
4단계: 자동 갱신 설정
인증서 등록 후 관리자 권한 PowerShell에서 아래 명령을 실행하여 Task Scheduler 자동 갱신 작업을 등록합니다.
$action = New-ScheduledTaskAction `
-Execute "wacs.exe의 전체 경로" `
-Argument '--renew --baseuri "https://acme.navercloudtrust.com/acme/directory"'
$trigger = New-ScheduledTaskTrigger -Daily -At "09:00"
$principal = New-ScheduledTaskPrincipal `
-UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-StartWhenAvailable
Register-ScheduledTask `
-TaskName "win-acme daily renew" `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force
등록된 작업은 매일 09:00에 실행되며 갱신 여부를 자동으로 판단합니다. 갱신이 필요한 경우 DNS-01 챌린지를 수행하고 IIS 바인딩을 무중단으로 교체합니다.
갱신 상태를 확인하려면 아래 명령을 실행합니다.
Get-ScheduledTaskInfo -TaskName "win-acme daily renew" |
Select-Object LastRunTime, LastTaskResult, NextRunTime
| LastTaskResult | 의미 |
|---|---|
0 |
갱신 성공 |
267011 |
갱신 불필요 (정상) |
| 그 외 | 실패 — 로그 확인 필요 |
갱신 로그는 win-acme 데이터 디렉터리 하위 Log\log-YYYYMMDD.txt 파일에서 확인할 수 있습니다.
인증서 확인
발급된 인증서는 Windows Certificate Store의 WebHosting 저장소에 저장됩니다. 아래 방법으로 확인합니다.
# IIS 바인딩 인증서 thumbprint 확인
Get-WebBinding | Where-Object { $_.protocol -eq "https" } |
Select-Object bindingInformation, certificateHash
# WebHosting 스토어 인증서 목록 조회
Get-ChildItem "Cert:\LocalMachine\WebHosting" |
Select-Object Subject, Thumbprint, NotAfter, Issuer
또는 Windows 인증서 관리자 (certlm.msc) → 인증서(로컬 컴퓨터) → 웹 호스팅 → 인증서에서 확인할 수 있습니다.
EAB 키 교체
EAB 키를 교체해야 하는 경우 아래 순서대로 진행합니다. 기존 ACME 계정 파일이 남아 있으면 새 EAB 키가 무시되고 기존 계정이 재사용되므로, 반드시 계정 파일을 먼저 삭제해야 합니다.
- 스크립트 또는 설정 파일에서 EAB 값을 새로 발급받은 값으로 교체합니다.
- win-acme 데이터 디렉터리에서
Registration_v2,Signer_v2,*.renewal.json파일을 삭제합니다. - 3단계의 발급 명령을 다시 실행합니다.
성공하면 새 계정으로 인증서가 발급되고 IIS 바인딩이 자동으로 교체됩니다. Task Scheduler 작업은 별도 수정이 불필요합니다.
수동 갱신
테스트 또는 긴급 대응이 필요한 경우 아래 명령을 사용합니다.
# 일반 갱신 (만료 조건 충족 시에만 실행)
wacs.exe --renew --baseuri "https://acme.navercloudtrust.com/acme/directory"
# 강제 갱신 (조건 무관하게 즉시 실행)
wacs.exe --renew --baseuri "https://acme.navercloudtrust.com/acme/directory" --force
# 상세 로그 출력하며 강제 갱신
wacs.exe --renew --baseuri "https://acme.navercloudtrust.com/acme/directory" --force --verbose
# Task Scheduler 작업 즉시 실행
Start-ScheduledTask -TaskName "win-acme daily renew"
# 등록된 renewal 목록 확인
wacs.exe --list --baseuri "https://acme.navercloudtrust.com/acme/directory"
문제 해결
| 증상 | 원인 | 조치 |
|---|---|---|
order invalid (DNS 검증은 valid) |
RSA 키 크기 불일치 | settings.json → Csr.Rsa.KeyBits: 2048 확인 |
Authorization pending 후 실패 |
DNS 전파 지연 | 생성 스크립트의 대기 시간을 90~120초로 증가 |
| TXT 레코드 검증 실패 | zone 탐색 실패 | NCP 콘솔에서 해당 도메인이 Global DNS에 zone으로 등록되어 있는지 확인 |
| API 401 / 403 오류 | API 키 오류 또는 권한 부족 | NCP IAM에서 Access Key 유효 여부 및 Global DNS 권한 재확인 |
| 한글 로그 깨짐 | 훅 파일 인코딩 오류 | 훅 파일을 UTF-8 BOM으로 재저장 |
| 오류 없이 조용히 실패 | --verbose 미사용 |
--verbose 옵션 추가 후 재실행 |
| EAB 교체 후 기존 계정 재사용 | Registration_v2 미삭제 |
EAB 키 교체 절차 참조 |
| Task Scheduler 실패 | SYSTEM 계정 권한 부족 | 작업 속성 → 최고 권한으로 실행 체크 확인 |
| IIS 바인딩 미갱신 | 인증서 저장소 불일치 | --certificatestore WebHosting 파라미터 확인 |
로그를 확인하려면 아래 명령을 실행합니다.
Get-Content "win-acme 데이터 경로\Log\log-$(Get-Date -Format yyyyMMdd).txt" -Tail 100
보안 권고
- API 키와 EAB 키가 포함된 설정 파일은 Git 저장소나 공유 폴더에 포함하지 마십시오.
- Ncloud API 키에는 Global DNS 서비스에만 최소 권한을 부여하는 것을 권장합니다.
- win-acme 데이터 디렉터리(ACME 계정 서명 키 포함)에 대한 접근 권한을 관리자 계정으로 제한하십시오.
- 인증서 개인키가 외부에 노출되지 않도록 주의하십시오.
- win-acme 및 PowerShell을 정기적으로 최신 버전으로 유지하십시오.
부록: Ncloud Global DNS 훅 스크립트 예시
아래는 Ncloud Global DNS API를 사용하는 DNS 훅 스크립트 예시입니다.
아래 스크립트는 참고용 예시입니다. 운영 환경에 맞게 충분히 검토하고 테스트한 후 사용하십시오. 스크립트의 수정, 설정, 실행 결과에 대한 책임은 사용자에게 있습니다.
config.ps1 — API 키 설정 파일
# ACME 서버 (변경 불필요)
$ACME_SERVER = "https://acme.navercloudtrust.com/acme/directory"
# EAB 키 — Certificate Manager 콘솔에서 발급받은 값으로 교체
$EAB_KID = "[EAB_KEY_ID]"
$EAB_HMAC_KEY = "[EAB_HMAC_KEY]"
# NCP Global DNS API 키 — NCP IAM 콘솔에서 발급 (Global DNS 권한 필요)
$NCP_ACCESS_KEY = "[NCP_ACCESS_KEY]"
$NCP_SECRET_KEY = "[NCP_SECRET_KEY]"
$NCP_DNS_API = "https://globaldns.apigw.ntruss.com"
# DNS 전파 대기 시간(초). 검증 실패 시 90~120으로 늘리십시오.
$DNS_PROPAGATION_WAIT = 60
# ACME 계정 이메일
$ACME_EMAIL = "[ADMIN_EMAIL]"
dns-create.ps1 — TXT 레코드 생성 훅
# dns-create.ps1 — win-acme DNS-01 생성 훅 (NCP Global DNS)
# UTF-8 BOM 인코딩으로 저장할 것
param(
[string]$Action,
[string]$Domain,
[string]$RecordName,
[string]$Token
)
$ConfigPath = Join-Path $PSScriptRoot "config.ps1"
. $ConfigPath
function Get-NcpHeaders {
param([string]$Method, [string]$Uri)
$timestamp = [long]([datetimeoffset]::UtcNow.ToUnixTimeMilliseconds())
$message = "$Method $Uri`n$timestamp`n$NCP_ACCESS_KEY"
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($NCP_SECRET_KEY)
$sig = [Convert]::ToBase64String(
$hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($message))
)
return @{
"x-ncp-apigw-timestamp" = "$timestamp"
"x-ncp-iam-access-key" = $NCP_ACCESS_KEY
"x-ncp-apigw-signature-v2" = $sig
"Content-Type" = "application/json"
}
}
# 와일드카드 처리
$Domain = $Domain -replace "^\*\.", ""
# Zone 탐색
$zoneUri = "/v1/ncpdns/domain?page=1&size=100"
$zones = (Invoke-RestMethod -Uri "$NCP_DNS_API$zoneUri" `
-Headers (Get-NcpHeaders "GET" $zoneUri)).domainList
$matchedZone = $zones | Where-Object {
$Domain -eq $_.name -or $Domain.EndsWith(".$($_.name)")
} | Select-Object -First 1
if (-not $matchedZone) {
Write-Error "[오류] NCP Global DNS에서 '$Domain'에 해당하는 zone을 찾을 수 없습니다."
exit 1
}
$zoneId = $matchedZone.domainId
$zoneName = $matchedZone.name
# TXT 레코드 호스트명 계산
if ($Domain -eq $zoneName) {
$hostName = "_acme-challenge"
} else {
$sub = $Domain -replace "\.$([regex]::Escape($zoneName))$", ""
$hostName = "_acme-challenge.$sub"
}
# TXT 레코드 생성
$createUri = "/v1/ncpdns/record/$zoneId"
$body = @{ type = "TXT"; host = $hostName; content = $Token; ttl = 60 } | ConvertTo-Json
$response = Invoke-RestMethod -Uri "$NCP_DNS_API$createUri" -Method POST `
-Headers (Get-NcpHeaders "POST" $createUri) -Body $body
if (-not $response.recordId) {
Write-Error "[오류] TXT 레코드 생성에 실패했습니다."
exit 1
}
# 레코드 ID 임시 저장 (삭제 훅에서 사용)
$tmpFile = "$env:TEMP\ncp_sid_$($Domain -replace '\*','_').json"
@{ recordId = $response.recordId; domainId = $zoneId } | ConvertTo-Json |
Set-Content $tmpFile -Encoding UTF8
# DNS 반영
$applyUri = "/v1/ncpdns/domain/$zoneId/apply"
Invoke-RestMethod -Uri "$NCP_DNS_API$applyUri" -Method PUT `
-Headers (Get-NcpHeaders "PUT" $applyUri) | Out-Null
Write-Host "DNS TXT 레코드 생성 완료. $DNS_PROPAGATION_WAIT 초 대기 중..."
Start-Sleep -Seconds $DNS_PROPAGATION_WAIT
dns-delete.ps1 — TXT 레코드 삭제 훅
# dns-delete.ps1 — win-acme DNS-01 삭제 훅 (NCP Global DNS)
# UTF-8 BOM 인코딩으로 저장할 것
param(
[string]$Action,
[string]$Domain,
[string]$RecordName,
[string]$Token
)
$ConfigPath = Join-Path $PSScriptRoot "config.ps1"
. $ConfigPath
function Get-NcpHeaders {
param([string]$Method, [string]$Uri)
$timestamp = [long]([datetimeoffset]::UtcNow.ToUnixTimeMilliseconds())
$message = "$Method $Uri`n$timestamp`n$NCP_ACCESS_KEY"
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($NCP_SECRET_KEY)
$sig = [Convert]::ToBase64String(
$hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($message))
)
return @{
"x-ncp-apigw-timestamp" = "$timestamp"
"x-ncp-iam-access-key" = $NCP_ACCESS_KEY
"x-ncp-apigw-signature-v2" = $sig
}
}
$Domain = $Domain -replace "^\*\.", ""
$tmpFile = "$env:TEMP\ncp_sid_$($Domain -replace '\*','_').json"
$sid = Get-Content $tmpFile -ErrorAction SilentlyContinue | ConvertFrom-Json
if (-not $sid) {
Write-Warning "[경고] 삭제할 레코드 정보를 찾을 수 없습니다."
exit 0
}
# TXT 레코드 삭제
$deleteUri = "/v1/ncpdns/record/$($sid.domainId)/$($sid.recordId)"
Invoke-RestMethod -Uri "$NCP_DNS_API$deleteUri" -Method DELETE `
-Headers (Get-NcpHeaders "DELETE" $deleteUri) | Out-Null
# DNS 반영
$applyUri = "/v1/ncpdns/domain/$($sid.domainId)/apply"
Invoke-RestMethod -Uri "$NCP_DNS_API$applyUri" -Method PUT `
-Headers (Get-NcpHeaders "PUT" $applyUri) | Out-Null
Remove-Item $tmpFile -Force -ErrorAction SilentlyContinue
Write-Host "DNS TXT 레코드 삭제 완료."