win-acme 기반 Windows Server 인증서 자동화

Prev Next

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 설치

  1. win-acme 공식 릴리스 페이지에서 v2.2.9 이상의 release, trimmed, standalone, 64-bit 버전을 다운로드합니다.
  2. 원하는 경로에 압축을 해제합니다.

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가 챌린지를 시작할 때 호출됩니다. 다음을 처리해야 합니다.

  1. 전달받은 도메인에 해당하는 DNS zone을 탐색합니다.
  2. _acme-challenge.<도메인> TXT 레코드를 생성합니다.
  3. DNS 변경 사항을 반영합니다.
  4. DNS 전파가 완료될 때까지 충분히 대기합니다. (권장: 60초 이상)

삭제 스크립트 (dns-delete)
챌린지 검증이 완료된 후 호출됩니다. 다음을 처리해야 합니다.

  1. 생성 스크립트에서 만든 TXT 레코드를 삭제합니다.
  2. 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 키가 무시되고 기존 계정이 재사용되므로, 반드시 계정 파일을 먼저 삭제해야 합니다.

  1. 스크립트 또는 설정 파일에서 EAB 값을 새로 발급받은 값으로 교체합니다.
  2. win-acme 데이터 디렉터리에서 Registration_v2, Signer_v2, *.renewal.json 파일을 삭제합니다.
  3. 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.jsonCsr.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 레코드 삭제 완료."