#TL;DR
HTTP方式とほぼ同じでいけます。
違いは認証用Tokenが反映されるまでにインターバルがある事くらいでした。
自分でDNSを引いて認証用Tokenが反映されているのを確認した後、VerifyすればOKです。
作ったものは https://github.com/nak1114/letsencrypt-dns01/ に置いておきます。
#きっかけ
Let's Encryptが18年1月にACME-v2になり、Wildcard SSL証明書を発行可能になるとアナウンスがあったがきっかけです。
intra.example.com
のようなあからさまにイントラ用のDomainでも今までは外に向けて公開していました。Wildcardに出来るのならこういうのを隠蔽できるだろうと、ACME-v1だけど一足先にDNS認証に切り替えてみようと思いました。
権威サーバの保守の方が面倒そうだけど、とりあえず問題が出るまでは運用してみようかなと。
#やったこと
##ACMEクライアント
欲しいのはACME-v2での自動化ですので、まずは現在の数多あるACMEクライアントからv2対応してくれそうなのを探す必要があります。
かるくググってみるとこれ( https://www.bountysource.com/issues/46237938-acme-v2 )が見つかったので、やる気はあるとみてこれに決定します。
##コード
基本的には流れはHTTPと同じで以下の通りです。
- レジストして
- 認証用トークンを貰って
- DNSサーバにトークンを反映して
- Verifyして
- 証明書発行する
レジスト
レジストした秘密鍵は保存して再利用します。秘密鍵がない時は新しく秘密鍵を作ってレジストする。
HTTPの時と同じです。
def initialize(zone={})
@zone = normalization(zone)
@client = set_client()
@log = Logger.new(@zone[:logfile], 5, 1024000)
end
def set_client
filename=@zone[:authkey]
if File.exist?(filename)
key = OpenSSL::PKey::RSA.new(File.read(filename))
return Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
end
key = OpenSSL::PKey::RSA.new(4096)
cli = Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
registration = cli.register(contact: "mailto:#{@zone[:mail]}")
registration.agree_terms
FileUtils.mkdir_p(File.dirname(filename))
File.write(filename, key.to_pem)
File.chmod(0400, filename)
cli
end
認証用トークン取得
トークン取得ですがauthorization.dns01
とする事でDNS認証用のトークンになるようです。
#get challenge token
authorization = @client.authorize(domain: domain)
challenge = authorization.dns01
token =%(#{challenge.record_name}.#{domain}. IN #{challenge.record_type} "#{challenge.record_content}"\n)
DNSサーバにトークンを反映
お金がないのでRoute53だの何だのは使いません。
zonefileにしこしこ書いてdnsサーバをreloadします。
zonefileはSOAのserial値をインクリメントしないとダメなので、そこら辺もここでやります。
トークンはコメントtoken area
を見つけて更新します。
def update_zonefile(token="")
filename=@core.zone[:zonefile]
content=File.read(filename)
#update serial
content.sub!(/^\s+(\d+)\s*\;\s*serial$/i) do |m|
@serial=[$1.to_i+1,@serial].max
$&.sub(/\d+/,@serial.to_s)
end
#delete old token
content.sub!(/^\; token area$.*\z/im) do |m|
"; token area\n"
end
#add new token
content+=token
#write zonefile
File.write(filename,content)
#reload name server
@core.zone[:command].each{|c| system(c)} if @core.zone[:command]
end
対応するzonefileがこれ。シリアル値の横にコメントでserial
と書いて、zonefileの末尾にコメントでtoken area
と書いてあれば準備OKです。
$TTL 86400
@ IN SOA ns.example.com. root.example.com. (
2017071700 ; Serial
3H ; refresh
15M ; retry
2W ; expiry
1D ) ; minimum
@ IN NS ns.example.com.
@ IN NS ns.example.net.
@ A 192.0.2.0
ns A 192.0.2.1
www A 192.0.2.2
bar.baz A 192.0.2.3
; token area
Verifyして
はまりポイントでした。HTTP認証のノリでトークンを書き込んだ直後にchallenge.request_verification
すると認証が通りません。この場合challenge.verify_status
がinvalid
を返してきます。
原因はLetsEncrypt側がトークンを読めないからなのですが、
waitとしてhostコマンドなどでDNSを引いて確認すると今度は浸透するまで時間が掛かりすぎます。
色々試してセカンダリDNSサーバ(SlaveDNSサーバ?)から確認出来ればLetsEncrypt側もトークンを読めるようなので、今回はセカンダリDNSサーバからトークンを読み取って一致してからVerifyしています。
#check DNS record
dns=Resolv::DNS.new(:nameserver => @zone[:nameserver])
cname="#{challenge.record_name}.#{domain}"
ctxt=challenge.record_content
@log.info "token #{sum},#{ctxt}"
begin
sleep 10
ret=dns.getresources(cname, Resolv::DNS::Resource::IN::TXT)
end until ret.size > 0 && ctxt==ret[0].data
#verify token
challenge.request_verification
sleep 5 while challenge.verify_status == "pending"
sum+=1 if challenge.verify_status=="valid"
# sum+=1 if t.challenge.verify_status=="invalid"
@log.info "verified! #{sum},#{domain}"
5. 証明書発行する
ここは特に問題なく証明書が発行されました。
# update_cert updates/create some certification files under serial dir.
def update_cert(serial)
rcsr={names: @zone[:domain]}
rcsr[:common_name]=@zone[:domain][0] if @zone[:domain].size > 1
csr = Acme::Client::CertificateRequest.new(rcsr)
certificate = @client.new_certificate(csr)
cdir=@zone[:certdir]+"/current"
rdir=@zone[:certdir]+"/#{serial}/"
FileUtils.mkdir_p(rdir)
File.write(rdir+@zone[:certname][:privkey ], certificate.request.private_key.to_pem)
File.write(rdir+@zone[:certname][:cert ], certificate.to_pem)
File.write(rdir+@zone[:certname][:chain ], certificate.chain_to_pem)
File.write(rdir+@zone[:certname][:fullchain], certificate.fullchain_to_pem)
FileUtils.rm(cdir,{force: true})
FileUtils.ln_s(rdir.chop,cdir,{force: true})
end
コード全景
簡単なrspec込みでgithub に置きました。
ちなみにこれ以上gemとしての体裁を整えるつもりはないです。
require 'resolv'
require 'logger'
require 'acme-client'
require 'fileutils'
require 'date'
# require 'mail'
module Letsencrypt
module Dns01
# Your code goes here...
end
end
class Letsencrypt::Dns01::BIND9
def initialize(cfg)
@serial = Date.today.strftime('%Y%m%d00').to_i
@core=Letsencrypt::Dns01::Core.new(cfg)
end
def update()
#add token and update serial
@core.authorize do |v|
update_zonefile(v)
end
@serial
end
def update_zonefile(token="")
filename=@core.zone[:zonefile]
content=File.read(filename)
#update serial
content.sub!(/^\s+(\d+)\s*\;\s*serial$/i) do |m|
@serial=[$1.to_i+1,@serial].max
$&.sub(/\d+/,@serial.to_s)
end
#delete old token
content.sub!(/^\; token area$.*\z/im) do |m|
"; token area\n"
end
#add new token
content+=token
#write zonefile
File.write(filename,content)
#reload name server
@core.zone[:command].each{|c| system(c)} if @core.zone[:command]
end
end
class Letsencrypt::Dns01::Core
Token = Struct.new(:domain, :challenge)
attr_reader :zone, :log
# initialize login to ACME server.
# And it creates an authrization key file, if necessary.
def initialize(zone={})
@zone = normalization(zone)
@client = set_client()
@log = Logger.new(@zone[:logfile], 5, 1024000)
end
# authorize gets the authrization/verification token from the ACME server according to the domain list.And update certification after verify all domain.
# return unknown.
def authorize
if expire?
@log.info "start update"
ret=@zone[:domain].reduce(0) do |sum,domain|
@log.info "authorize #{sum},#{domain}"
#get challenge token
authorization = @client.authorize(domain: domain)
challenge = authorization.dns01
#update DNS record
yield(%(#{challenge.record_name}.#{domain}. IN #{challenge.record_type} "#{challenge.record_content}"\n))
#check DNS record
dns=Resolv::DNS.new(:nameserver => @zone[:nameserver])
cname="#{challenge.record_name}.#{domain}"
ctxt=challenge.record_content
@log.info "token #{sum},#{ctxt}"
begin
sleep 10
ret=dns.getresources(cname, Resolv::DNS::Resource::IN::TXT)
end until ret.size > 0 && ctxt==ret[0].data
#verify token
challenge.request_verification
sleep 5 while challenge.verify_status == "pending"
sum+=1 if challenge.verify_status=="valid"
# sum+=1 if t.challenge.verify_status=="invalid"
@log.info "verified! #{sum},#{domain}"
sum
end
#delete DNS record
yield("")
#cartificate
if ret==@zone[:domain].size
serial=Time.now.strftime("%Y%m%d%H%M%S")
@log.info "update_cert #{serial}"
update_cert(serial)
end
@log.info "complete update."
else
@log.info "skip update"
end
@log.close
end
# private
def normalization(zone)
zone[:endpoint]||="https://acme-staging.api.letsencrypt.org"
zone[:mail]||="[email protected]"
zone[:margin_days ]||=30
zone[:warning_days]||=7
domains =zone[:domains ]||zone[:domain ]
zone[:domain ]=domains
zone[:domain ]=[domains ] unless domains.instance_of?(Array)
commands=zone[:commands]||zone[:command]||[]
zone[:command]=commands
zone[:command]=[commands] unless commands.instance_of?(Array)
zone[:certdir]||=File.expand_path(File.dirname($0))
zone[:certname]||={}
zone[:certname][:privkey ]||="privkey.pem"
zone[:certname][:cert ]||="cert.pem"
zone[:certname][:chain ]||="chain.pem"
zone[:certname][:fullchain]||="fullchain.pem"
zone[:logfile]||=STDOUT
zone
end
def set_client
filename=@zone[:authkey]
if File.exist?(filename)
key = OpenSSL::PKey::RSA.new(File.read(filename))
return Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
end
key = OpenSSL::PKey::RSA.new(4096)
cli = Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
registration = cli.register(contact: "mailto:#{@zone[:mail]}")
registration.agree_terms
FileUtils.mkdir_p(File.dirname(filename))
File.write(filename, key.to_pem)
File.chmod(0400, filename)
cli
end
# update_cert updates/create some certification files under serial dir.
def update_cert(serial)
rcsr={names: @zone[:domain]}
rcsr[:common_name]=@zone[:domain][0] if @zone[:domain].size > 1
csr = Acme::Client::CertificateRequest.new(rcsr)
certificate = @client.new_certificate(csr)
cdir=@zone[:certdir]+"/current"
rdir=@zone[:certdir]+"/#{serial}/"
FileUtils.mkdir_p(rdir)
File.write(rdir+@zone[:certname][:privkey ], certificate.request.private_key.to_pem)
File.write(rdir+@zone[:certname][:cert ], certificate.to_pem)
File.write(rdir+@zone[:certname][:chain ], certificate.chain_to_pem)
File.write(rdir+@zone[:certname][:fullchain], certificate.fullchain_to_pem)
FileUtils.rm(cdir,{force: true})
FileUtils.ln_s(rdir.chop,cdir,{force: true})
end
# expire? check rest days by the current public key .
# return true if no file or file is expired.
def expire?
fname=@zone[:certdir]+"/current/"+@zone[:certname][:cert]
return true unless File.exist?(fname)
cert = OpenSSL::X509::Certificate.new(File.read(fname))
rest = cert.not_after - Time.now
return false if rest > (@zone[:margin_days]*24*60*60)
return true
end
end
if $0 == __FILE__
Letsencrypt::Dns01::BIND9.new({
name: 'example.com',
zonefile: 'spec/data/example.com.zone',
nameserver: [ '203.0.113.0' ],
domains: [
'example.com',
'www.example.com'
],
authkey: 'spec/data/key/example.com.pem',
certdir: 'spec/data/example.com',
logfile: 'spec/data/letsencrypt_example.com.log',
commands: [
#'service nsd restart',
#'nginx -s reload',
],
endpoint: 'https://acme-staging.api.letsencrypt.org',
mail: '[email protected]',
}).update()
end
#やってみて
HTTP認証からDNS認証に切り替えただけで最初の懸念だったintra.example.com
のAレコードが取り除けてしまった。もうこれでよいのではないだろうか。いや、SANだと証明書の拡張領域に思いっきりintra.example.com
って出てくるので駄目だ。
でもWildcard SSLも魅力的なのでLetsEncryptが対応したら追従する予定です。
##参考文献
RubyでLet's Encryptのスクリプト http://qiita.com/sawanoboly/items/f23e73c613e9454076b8