Let's EncryptのDNS認証を試してみる

#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と同じで以下の通りです。
1. レジストして
2. 認証用トークンを貰って
3. DNSサーバにトークンを反映して
4. Verifyして
5. 証明書発行する

### レジスト
レジストした秘密鍵は保存して再利用します。秘密鍵がない時は新しく秘密鍵を作ってレジストする。
HTTPの時と同じです。

```ruby:Core#set_client
  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認証用のトークンになるようです。

```ruby:Core#authorize
        #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`を見つけて更新します。

```ruby:BIND9#update_zonefile
    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です。

```zonefile:example.com.zone
$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しています。

```ruby:Core#authorize
        #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. 証明書発行する
ここは特に問題なく証明書が発行されました。

```ruby:Core#update_cert
  # 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](https://github.com/nak1114/letsencrypt-dns01/blob/master/lib/letsencrypt/dns01/core.rb) に置きました。
ちなみにこれ以上gemとしての体裁を整えるつもりはないです。

```ruby:core.rb
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]||="root@example.com"
    
    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: 'hogehoge@hotmail.com',
  }).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