CI/CDでDNSSEC署名
はじめに
いちばん簡単にDNSSEC署名する方法は、自前では権威サーバを運用せず、DNSSEC署名対応の権威DNSホスティングサービスに自分のゾーンを預けること。WebUIでポチっとなするだけであとは全自動。めちゃ簡単。
この記事ではその次に簡単な方法を解説するよ。
DNSSEC署名のための権威サーバ構成
まずはどんな構成が考えられるか。
構成その1: 2段構成
一般的な権威DNSサーバの構成は、以下の図のように外部公開用の権威サーバと、ゾーン管理用で内部のみアクセス可能な権威サーバの2段構成にする(プライマリ/セカンダリ構成は時代遅れなので忘れるべし)。
DNSSECの署名には有効期間が設定される。そのため、人間がゾーンの中身を変更しない場合でも、定期的に再署名をおこなって有効期間を更新しなければならない。通常、この定期再署名は自動化されている。DNSSECが難しい、めんどくさいと言われていたのは、この自動化のサポートが不十分だったころの話で、現在はこの問題は解消されている。難しくもめんどくさくもない。
しかし、自動化できるようになった現在なら何の問題もなくDNSSEC署名できるかというとそうとも言えない。上のような2段構成では、人間が編集するゾーンファイルと、ゾーン管理サーバが定期再署名をおこなうゾーンファイルはどちらも同じものになる。そのため、以下のような問題が起きる。
- 運が悪いと、人間による編集とサーバによる定期再署名のタイミングが競合する
- これを避けるために、サーバ側の処理を一時停止/再開するコマンドが用意されている
- が、一時停止したまま再開するのを忘れると再署名されず有効期限切れの危険がある
- サーバがゾーンファイルを書き換えるので、コメントなどが失われ可読性が下がる
- サーバがゾーンファイルを書き換えるので、履歴管理がしにくくなる
これは自動化の問題ではなく、人間が扱うファイルとサーバが扱うファイルが同一であることが原因で起きる問題である。つまり、この問題を解消するにはそれぞれが別々のファイルを扱うようにすればよい。
構成その2: 3段構成(ゾーン転送)
そこでゾーン管理サーバを分割する。人間が編集するゾーンを置くサーバと、DNSSEC署名をおこなうサーバで役割をわけて、以下のような3段構成にする。
人間はゾーン管理サーバで未署名ゾーンを編集し、履歴管理する。そのゾーンを未署名のまま署名サーバにゾーン転送し、DNSSECまわりのもろもろは署名サーバがすべておこなう。人間がいじるファイルと署名対象のファイルが別のサーバで管理されるので、前述した2段構成の問題はすべて解消する。
この構成については以下の記事で説明しているので、詳しくはそちらを参照してほしい。
上の記事はKnot DNSでやっているが、以下のようにBINDでも可能(要9.16以降)。
この構成に大きな問題はなく、現時点でも十分以上に推奨できるのだが、以下のような若干気になる点もあった。
- ゾーン管理サーバはたいした仕事もしないのに常時稼動していないといけない
- ゾーン更新の際に履歴管理をサボることもできてしまう
ということで、この構成をさらに改良することを考える。
構成その3: 2段構成(動的更新)
署名サーバにゾーンファイルを送る方法としてゾーン転送を使っているから、送信側もDNSサーバとして動いていないといけないのである。別の方法で送ってやれば常時稼動のサーバは不要になる。つまり、dynamic update (RFC2136)で更新する。
この場合、どのようにdynamic updateをおこなうかという問題が出てくる。BINDに付属のnsupdateコマンドを実際に使ったことのある人ならわかると思うが、あんなもんを常用するのは正気の沙汰ではない。もっとまともなツールが必要である。
動的更新がRFCになって20年が経ち、近年やっと実用に耐えるツールが出てきた。それがoctodnsとDNSControlである。サーバが持っているゾーンと、これから変更するゾーンの比較し、差分だけを追加・変更・削除してくれる。
この構成にすることで、ゾーン管理サーバは常時稼動しておく必要がなくなる。以下のようなコマンドでサーバにゾーンの編集内容を反映できる。
% vi config.yaml # octodnsの設定ファイル編集(最初の1回だけ)
% vi example.com.zone # ゾーン編集
% git diff # 編集差分確認
% octodns-sync --config-file=config.yaml # dry-run
% git commit -a # 問題なさげならコミット
% octodns-sync --config-file=config.yaml --doit # ゾーン更新
署名サーバが適切に設定されていれば、DNSSEC署名も自動でおこなわれる。
構成その4: 3段構成(CI/CD)
しかし、人間は怠惰な生き物である。軽微な修正では履歴更新をサボりがちである。これまで自分が個人的に使っているドメイン名は前述の構成その2の3段構成で動かしていた。しかし、さっき確認したら最後にゾーンの編集内容をgit commitしたのが6年前だった。自分しか使わないお気楽なドメイン名とはいえ、いくらなんでもサボりすぎだろう。これはよろしくない。
そこで、リポジトリにコミットしなければサーバに反映できないしくみにしてしまおう。つまり、ゾーン更新処理のコマンドを人間が直接実行するのではなく、gitリポジトリの中から実行するのである。
ゾーン管理をCI/CDのフローに組み込むという考え方については以下の記事が詳しい。というか、導入の動機や目的こそ異なるが、やるべきことはこの記事とまったく同じである。octodnsから更新する先の権威サーバがクラウドサービスなのか手元の署名サーバなのかの違いだけだ。
サーバがひとつ増えてしまったが、共用のGitHub/GitLabでもいいので問題ないだろう(うちはセルフホストのgitlabを使ってるが)。
実際の設定
ということで、その4のCI/CDを使う構成を実際に作ってみる。
権威サーバ
フロントの権威サーバは、ゾーン転送できるなら何でも好きなものでよい。NOTIFYを受け取ってゾーン転送するだけの単純な設定でいいので詳細は略。
権威DNSホスティングサービスによってはセカンダリサーバとして利用できるメニューを用意しているところもあり、そういったサービスを利用するなら自前で動かす必要すらない。
署名サーバ
ここではKnot DNSを使う。以下設定例。
server:
listen: [ 172.16.1.1 ]
automatic-acl: on
# "keymgr -t keyname"の実行結果をコピペ
key:
- id: update
algorithm: hmac-sha256
secret: xxxx
remote:
- id: secondary # フロントの権威サーバ
address: [ 172.16.1.2, 172.16.1.3 ]
- id: resolver # DSレコードの登録が完了したか確認するために問い合わせるDNSサーバ
address: [ 127.0.0.53 ] # ここではフルリゾルバを指定しているが、レジストリの権威サーバでもよい
acl:
- id: update # CI/CD runnerからのゾーン転送とdynamic updateの許可
key: update # 上のkey節で指定したid
address: [ ... ] # CI/CD runnerのIPアドレスのリスト(後述の注意点参照)
action: [ update, transfer ]
- id: xfer # フロントの権威サーバからのゾーン転送の許可
address: [ 172.16.1.2, 172.16.1.3 ]
action: [ transfer ]
submission:
- id: default
parent: resolver # remote節で指定したDS確認先サーバのid
policy:
- id: default
nsec3: on # 不在証明はNSEC3
nsec3-salt-length: 0 # RFC9276
ksk-submission: default
cds-cdnskey-publish: none
template:
- id: default
storage: "/var/lib/knot"
file: "%s.zone"
notify: secondary
acl: [ update, xfer ]
dnssec-policy: default
dnssec-signing: on
zone: # ゾーンを列挙
- domain: example.com
- domain: example.net
- domain: example.org
dnssec-signing: off # DNSSEC署名しないゾーン
とくに難しい設定はしていない。ふつーにプライマリサーバとしてゾーンを運用する設定に、DNSSECまわりの設定(policy, submission, dnssec-*)とdynamic updateを受け付ける設定(key, acl)をそれぞれ追加しただけ。あとは/var/lib/knotにゾーンファイルを放りこんで起動し、DSレコードを親ゾーンに登録すればよい。
ここではKnotを使っているが、BINDでももちろん可能。
CI/CD
ここではGitLab CIを使う。なぜなら自前で動かしてるものがあるので。GitHub Actionsでもいいし、こういったWebサービスを使わずに生のgit hookを書いてもできるはず。
octodnsの設定
こんな感じの config.yamlを用意する。ゾーンファイルから読み込んでdynamic updateで更新する設定。
providers:
zonefile:
class: octodns_bind.ZoneFileSource
directory: .
file_extension: .zone
rfc2136:
class: octodns_bind.Rfc2136Provider
host: 172.16.1.1 # 上で構築した署名サーバのIPアドレス
key_name: env/TSIG_KEY
key_secret: env/TSIG_SECRET
key_algorithm: hmac-sha256
zones: # ゾーンを列挙
example.com.: &default
sources:
- zonefile
targets:
- rfc2136
example.net.: *default # &default を設定したゾーンと同じパラメータを利用
example.org.: *default
ゾーンは絶対ドメイン名(末尾ドットあり)で指定しなければならないようだ。
env/XXX は $XXXという環境変数を参照せよという意味。上のknot.confで設定したTSIG鍵の情報を設定する。認証情報なのでgitリポジトリ内のファイルには書かず、GitLabのWebUIから設定→CI/CD→変数で設定する。
GitLab CIの設定
.gitlab-ci.ymlの中身。
default:
image: octodns/bind
before_script:
- apt update
- apt install -y curl jq knot-dnssecutils
dryrun:
stage: test
script:
- ./dnssync.sh --config-file=config.yaml
rules:
- if: $CI_COMMIT_BRANCH == "stage"
deploy:
stage: deploy
script:
- ./dnssync.sh --config-file=config.yaml --doit
rules:
- if: $CI_COMMIT_BRANCH == "master"
stageブランチにコミットするとdryrunというジョブが、masterブランチにコミットするとdeployというジョブが走る。実行するたびに毎回apt installするのはアレなので、ちゃんとインストール済みのdockerイメージを用意したほうがいいかもね。
この中で呼んでいるdnssync.shというスクリプトの中身は以下のとおり。
#!/bin/sh
checkzone(){
## require knot-dnssecutils
kzonecheck -v $1
## require bind9-utils
#named-checkzone {$1%.zone} $1
## require nsd
#nsd-checkzone {$1%.zone} $1
## require validns
#validns -p cname-other-data -p mx-alias -p ns-alias -z {$1%.zone} $1
}
postslack(){
jq -sR '{ text: [ "*Job: " + $ENV.CI_JOB_NAME + "*", "```", (. | sub("\n*$";"")) , "```" ] | join("\n") }' | curl -s -X POST -H 'Content-type: application/json' -d@- $SLACK_WEBHOOK_URL
}
result=0
for z in *.zone; do
checkzone $z || result=$?
done > checkzone.out 2>&1
cat checkzone.out
if [ $result -ne 0 ]; then
postslack < checkzone.out
exit $result
fi
octodns-sync --log-stream-stdout $@ > synczone.out
result=$?
cat synczone.out
sed -n '/^\*/,$p' synczone.out | postslack
exit $result
octodns自体でもゾーンの内容チェックはしているが、けっこう穴がある。実際、テストコードで使われてるゾーンファイルにいくつか間違いがある(グルーなしの委任など)。そのため、dryrunに成功するのにdeployに失敗するという事態が起きないよう、octodnsとは別にゾーンのバリデーションをしておいたほうがよい。今回の例では更新先のサーバがKnotなのでそれにあわせてknot付属のkzonecheckを使っているが、named-checkzoneなどでもよい(その場合、.gitlab-ci.ymlでapt installしておく)。
ハートビーツさんの先行例をまねて、slackに結果をポストしている。事前にslackのwebhook URLを取得しておいて(具体的な方法は割愛)、$SLACK_WEBHOOK_URLとしてGitLabのWebUIから環境変数に設定しておく。slackに結果を通知しないのであればごっそり削除すべし。
やってみた
実際にこの構成でゾーンを更新してみるよ
間違った内容を登録する
stageブランチで以下のレコードを追加し、commit、push。シリアル番号はいじらなくてよい。
foo IN TXT "abc
slackの画面キャプチャ。

TXTレコードを正しく"で閉じてないのでエラーになった。修正しておらずエラーにならないゾーンが他に2つあって、No error foundはそれらに対するチェック結果。まぎらわしいからスクリプトいじったほうがいいかなぁ。
修正してコミットする
foo IN TXT "abc"

今度は問題なさげ。dryrunなので更新はされない。今回の件とは直接は関係ないが、適切なコミットメッセージを書く習慣をつけよう。
masterブランチにマージする
組織の運用方針によってはpull/merge requestを出そう。うちは組織でもなんでもないただの個人なので手元でやる。
% git checkout master
% git merge stage
% git push

適切なコミットメッ(ry
問い合わせてみるとその内容でちゃんと応答する。署名され、検証に成功したadフラグつき。
% dig foo.chigumaya.jp txt +dnssec +multi
; <<>> DiG 9.10.6 <<>> foo.chigumaya.jp txt +dnssec +multi
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 7421
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags: do; udp: 1232
;; QUESTION SECTION:
;foo.chigumaya.jp. IN TXT
;; ANSWER SECTION:
foo.chigumaya.jp. 86211 IN TXT "abc"
foo.chigumaya.jp. 86211 IN RRSIG TXT 13 3 86400 (
20251201083608 20251117070608 20523 chigumaya.jp.
mhgpxy3Cos0ih9PvRG8Fjro7ZxFOe+JbIrug5nPnsOZv
lHxg9HQnXLF4qRBoUN7sxtwUDvMEXw8zJAs//HF0jA== )
;; Query time: 66 msec
;; SERVER: 192.168.11.123#53(192.168.11.123)
;; WHEN: Mon Nov 17 17:42:46 JST 2025
;; MSG SIZE rcvd: 169
めでたしめでたし。
注意すべき点
動的更新のアクセス制限
署名サーバに対するゾーンの更新はCI/CDコンテナからおこなわれる。悪意の第三者から更新されないようにアクセス制限が必須だが、どのように制限するべきか。
GitHub/GitLabの共用ランナーから更新させる場合、IPアドレスによる制限は実質的に不可能。
- GitHub Actions
- https://api.github.com/meta
- めっちゃ大量にリストされているし、頻繁に追加削除があるだろうし、いちいち設定するのは現実的ではない
- GitLab CI
- https://docs.gitlab.com/user/gitlab_com/#ip-range
- 要約すると、「どのIPアドレスが使われるのか俺らも知らん」
また、署名サーバは共用ランナーからアクセスできるようにプライベートアドレス空間に置くことはできず、外部からアクセスできるようにしておかなければならない。せっかくのhidden masterなのに、隠すことができない。
IPアドレス制限はなくても、TSIG認証により外部の第三者からの悪意の改竄は防げるので、それで何か問題があるわけではない。しかし、TSIGだけに頼らない制限も欲しい、hidden masterは外部に晒したくないと考えるなら、ランナーだけはセルフホストしておき、自前のランナーからのアクセスだけを許可するように構成する必要がある。
ゾーン転送のアクセス制限
更新が必要な差分を求めるために、まずゾーン転送で現在のゾーンを取得するが、octodnsではこのゾーン転送でもTSIGを使うようだ。ゾーン転送にTSIGは不要(逆にTSIGつきだとコケる)、という設定だとこれが失敗する。TSIGつきのゾーン転送ができるようにしておくこと。
大量の更新
octodnsでは一度に多数のリソースレコードを更新しようとすると、安心機能が働いてストップがかかるらしい。--forceつきで実行すればこれを回避できるみたいだけど、今回みたいにCI/CDコンテナの中から実行する場合にはどうやって指定すればいいのかね。手元の環境は小規模でこれにひっかかるような使い方はしないので、とりあえず放置。
対応していないタイプ
あくまでoctodnsのBINDプロバイダ(RFC1035形式ゾーンファイル、ゾーン転送、dynamic update)を利用する場合。Route53などほかのプロバイダではまた異なる。
- SOAレコード
更新されない。こういうツールを使う場合シリアル値はまったく関係なく、であればめったに更新する必要はないのでとくに困らない。ゾーンファイルのシリアル値を増やしちゃいけないわけではない(無視されてサーバの方で勝手に採番されるだけ)。無視されるけど記述しておかないとエラーになるのでてきとーに書いておきましょう。どーしても変更しなければならない場合は手で変更する必要があるのでめんどくさい。
- ゾーン頂点のNSレコード
ゾーン頂点のNS(権威のNS)は更新対象にならない。ほんとにNSレコードを変更する必要があるとき(権威サーバを変更するとき)はoctodnsを使わず手でゾーンを編集する必要がある。
更新されないならテキトーでいいかというとそうではなく、ゾーンファイルに記述した値と一致してるかどうかはちゃんとチェックしているようで、でたらめな値だとエラーになる(チェックさせないオプションもあるっぽい。未検証)。
なお、ゾーン頂点以外のNSレコード(委任のNS)はちゃんとゾーンファイルに記載したとおりに更新される。
- DS、HTTPS、SVCBレコード
これを書いてる時点では、公式のdockerイメージでは対応していないしドキュメントにも記述がない。が、リポジトリ上は先日対応されたので次にdockerイメージが更新されると対応されるはず。たぶん。
逆に、対応しているものは以下に記載あり。
おまけ: DNSControlの場合
今回はoctodnsを採用したが、DNSControlでもできるはず(試してない)。
DNSControlはjavascriptによる独自形式のゾーンファイル(dnsconfig.js)が基本のようだ。一般的なRFC1035形式のゾーンファイルを扱えないわけではないが、更新するときはjs以外のソースを使えないっぽい。つまり、RFC1035形式からdnsconfig.jsに変換し、そのdnsconfig.jsからdynamic updateをおこなうという2段ステップになりそう。たぶん。どーせ自動化するならステップが増えても大した手間じゃないけど。
ただ、こいつは設定ファイルがJSONなんだ…。コメントの書けないJSONを人間にメンテさせるとか正気の沙汰じゃないんだ…(JSONCじゃないっぽい)。
おしまい
というわけで、ゾーンファイルをgitレポジトリにpushするだけでDNSSEC署名して公開してくれる権威サーバができたよ。
Discussion