Operations2026.08.10 · 10 min read

LAN の中だけで動く HTTPS を組む — 実在する TLD にワイルドカードを張らないという判断

NCP認証基盤・クラウドインフラ

自宅の LAN では、ブラウザに myapp.dev と打つとアプリが HTTPS で開く。証明書の警告は出ないし、端末ごとに /etc/hosts を書いてもいない。やっていることは dnsmasq 1つと Caddy 1つの設定だけで、そのほとんどは CLI が生成している。自宅サーバーについて書く3本のうちの2本目として、ここでは名前解決と TLS だけを掘る。中心にあるのは、実在する TLD にワイルドカードを張らないという判断だ。

.home はワイルドカード1行で済んでいた

始めたときのドメインは .home だけだった。LAN の DNS は dnsmasq をネイティブに動かし、設定はこれだけで足りていた。

services/dns/dnsmasq.conf
# Wildcard DNS: all *.home queries resolve to this server
address=/home/192.168.0.200

# Upstream DNS (forward non-.home queries)
server=8.8.8.8
server=8.8.4.4

# Listen on all interfaces (don't specify listen-address to bind all including loopback)

# Don't read /etc/resolv.conf (avoid loops)
no-resolv

# Don't poll /etc/resolv.conf for changes
no-poll

# Cache size
cache-size=1000

address=/home/192.168.0.200 の1行で、.home 配下のホスト名はすべてサーバーに向く。新しいアプリを足しても DNS 側は何もしなくてよい。共有 PostgreSQL に LAN から繋ぐときの postgres.home のようなホスト名も、登録した覚えがないまま引ける。この気楽さが.home を今も残している理由になっている。

no-resolvno-poll が入っているのは、このホスト自身が LAN のリゾルバだからだ。 /etc/resolv.conf を読ませると、上流として自分を指しかねない。転送先は設定ファイルに直接書いて、外の状態に依存させないようにしてある。

1つだけ、バインド先で手を入れた記録が残っている。当初は listen-address=0.0.0.0 を明示していたが、これを削除した。コミットに残っている理由は 「listen-address を指定しないほうが loopback を含む全インターフェースにバインドする」 というものだ。どんな症状で気づいたかまでは記録に残っていない。

名前解決を握る前に、53 番を空ける

Ubuntu の初期状態では 53 番は systemd-resolved が握っている。dnsmasq を入れる前に、これを停止して無効化しておかないと起動に失敗する。セットアップスクリプトでは、インストール時だけでなくすでに dnsmasq が入っている経路でも 毎回 systemd-resolved の停止を確認するようにしてある。何度流しても同じ状態に落ち着くスクリプトにしておきたかったからで、53 番が空いていることは、1回確認して終わりにできる前提として書いていない。

設定ファイルはリポジトリの実体を /etc/dnsmasq.d/home.conf にシンボリックリンクしている。ここが後で効いてくる。dnsmasq の設定は drop-in ディレクトリに複数ファイルを置ける構造になっていて、手で書く分と機械が書く分をファイル単位で分離できる

既定を .dev にしたとき、張らなかった1行

デプロイの既定ドメインは、途中で <name>.home から <name>.dev に変えた。ここで最初に決めたのが、.dev にはワイルドカードを張らないということだった。

address=/dev/192.168.0.200 と1行足せば実装は終わる。.home とまったく同じ書き方で、追加のコードは1行も要らない。だがそれをやると、.dev 配下のホスト名がすべて自宅サーバーに向く。*.workers.dev のような実在の公開サイトが、この LAN に繋がっている全端末から見えなくなる。自分のサーバーの都合で、家のネットワーク全体の名前解決を書き換えることになる。

.home でこれが許されていたのは、そこに公開サイトが存在しないからにすぎない。 つまり許容されていたのはワイルドカードという手法ではなく、その TLD が誰のものでもないという前提のほうだった。前提が変わったのに手法だけ持ち込むと壊れる。

QUERYANSWERdb.home登録は要らないmyapp.devdeploy 時に登録済みweb.dev未登録の実在サイトdnsmasq :53address=/home/…ワイルドカード 1 行address=/myapp.dev/…個別エントリ 1 行(no match)upstream へ転送192.168.0.200Caddy :443 へ192.168.0.200Caddy :443 へ8.8.8.8 / 8.8.4.4公開 DNS の答えのまま
同じ dnsmasq の中で、ワイルドカード・個別エントリ・素通しの3本が並んでいる

3本目の車線が見えるかどうかが分かれ目だと思う。登録していない web.dev は dnsmasq のどのエントリにも一致せず、上流にそのまま転送されて公開 DNS の答えが返る。ワイルドカードを張るというのは、この車線を潰すということだ。

登録は deploy 時の1行、削除も1行

代わりに採ったのは、デプロイのたびにエントリを1件足し、削除のたびに1件消す方式だ。書き込み先は drop-in の中の専用ファイルで、リポジトリからリンクしている手書きの設定とは別ファイルになっている。全行を自作の CLI(nr)が書き出すので、人間がこのファイルを開くことはない。

CLI が生成する drop-in ファイル
# /etc/dnsmasq.d/nishioka-custom.conf
# 全行を CLI が書き出す。人間は開かない。
address=/myapp.dev/192.168.0.200
address=/shop.dev/192.168.0.200

面白いのは、ここで使っているディレクティブが .home のワイルドカードとまったく同じ address= だということだ。実装上は 1 行の生成関数しかない。

エントリの生成(CLI)
return fmt.Sprintf("address=/%s/%s", domain, serverIP)

違うのは、ドメインツリーのどこまでを自分のものだと主張するかだけになる。しかも address=/myapp.dev/… myapp.dev だけでなく *.myapp.dev にも効く。個別エントリもそれ自体は小さなワイルドカードで、問題はワイルドカードかどうかではなく、根から何段目に置くか のほうだった。.dev の直下に置けば TLD 全体を、myapp.dev に置けば自分でつけた名前の下だけを引き受ける。

この非対称性は挙動にも出ている。.home のドメインが渡されたとき、追加も削除も何もせずに返る。すでにワイルドカードが引き受けているので、書くと二重になるだけだからだ。追加は同じ行があれば書かずに返り、削除は無ければ何もせずに返る。デプロイと削除は何度叩いても同じ状態に落ち着く。

それでも副作用は残っている。アプリに web と名前をつければ、LAN の中からは本物の web.dev が見えなくなる。これは消せていない。消せていないので、リポジトリの制約リストに文章で書いてある。

副作用を消せないときに次善なのは、副作用の大きさを選べる形にしておくことだと思っている。ワイルドカードは大きさを選べない。個別エントリは、名前をつけた本人が名前を変えれば回避できる。

証明書は Let's Encrypt から取らない

名前が引けても、そのままでは http:// でしか繋がらない。.home にせよ LAN 内でしか解決しない .dev のホストにせよ、外から到達できない以上、公的 CA は証明書を出せない。

Caddy には内部 CA が組み込まれていて、これを使うとローカル専用の証明書を自分で発行してくれる。グローバル設定は2行だけだ。

services/caddy/Caddyfile.template
# Global options
{
	# Use internal CA for *.home domains (no Let's Encrypt)
	local_certs
	# Disable external ACME
	auto_https disable_redirects
}

# === App entries below (managed by CLI) ===
# {{APPS}}

local_certs で発行元を内部 CA に固定し、auto_https disable_redirects で HTTP からの自動リダイレクトを切る。# {{APPS}} がプレースホルダで、アプリごとのブロックはここに差し込まれる。

01
dns query端末が myapp.dev を引く。dnsmasq が個別エントリに一致して 192.168.0.200 を返す。
02
https request端末は 192.168.0.200:443 へ、SNI に myapp.dev を載せて接続する。
03
tls terminateCaddy が内部 CA で発行した証明書で TLS を終端する。外部の ACME は使わない。
04
match site block生成済みの Caddyfile からドメインが一致するブロックを選ぶ。
05
proxy or serveDocker アプリなら reverse_proxy でループバックのポートへ、静的サイトなら file_server で配る。
端末がアプリに届くまでの5段

通常の Docker アプリはループバックのポートへのリバースプロキシ、静的サイトは file_server try_filesになる。ドメインごとに1ブロック、証明書は各ブロックの tls internal で内部 CA から出る。

/srv/caddy/Caddyfile(生成物)
myapp.dev {
	tls internal
	reverse_proxy localhost:3001
}

docs.dev {
	tls internal
	root * /home/<user>/git/docs/dist
	file_server
	try_files {path} /index.html
}

警告が出ないところまでは自動にならない

ここが、この構成でいちばん「自動 HTTPS」という言葉から外れる部分だ。サーバー側の証明書は自動で出る。だが、その証明書を発行した CA を端末が知らないので、ブラウザは当然警告を出す。手作業が残るのはサーバー側ではなく端末側で、しかも端末を増やすたびに再発する。

ルート証明書を取り出すスクリプトを1本置いてある。証明書の実体は Caddy のデータディレクトリの下、pki/authorities/local/root.crt にある。

ルート CA の取り出し(CA 未生成のとき / 生成済みのとき)
$ ./scripts/export-root-ca.sh
Error: Caddy root CA not found at /srv/caddy/data/caddy/pki/authorities/local/root.crt
Make sure Caddy has started and generated its internal CA.
Try: curl -k https://nishioka.dev  (to trigger CA generation)

$ ./scripts/export-root-ca.sh
Root CA exported to:
  /srv/caddy/root-ca.crt
  /srv/samba/share/root-ca.crt  (Samba share)

CA がまだ生成されていない状態で叩くと、上のエラーで止まる。これは失敗ではなく順序の問題だ。内部 CA は Caddy の起動時ではなく、最初に TLS を張る必要が生じたときに作られる。だからスクリプトは、証明書が無いときに「一度 HTTPS を叩いて CA を作らせろ」という案内を出すようにしてある。エラーメッセージに次の一手を書いておくと、半年後の自分が調べ直さずに済む。

取り出したあと、端末側でやることは次のとおり。

  • ルート証明書を、共有フォルダ経由で各端末に配る(母艦の共有に同じファイルを置いている)。
  • Mac は System キーチェーンに入れたうえで、Caddy Local Authority の Trust を「常に信頼」に変える。入れただけでは信頼されない。
  • iPhone / iPad はプロファイルをインストールしたあと、設定 → 一般 → 情報 → 証明書信頼設定でもう一度スイッチを入れる。ここも2段階になっている。

Mac も iOS も、インストールと信頼が別の操作になっているのが引っかかりどころだった。証明書を入れた直後は何も変わらないので、失敗したと勘違いして入れ直したくなる。手順をスクリプトの出力そのものに書き込んであるのはそのためで、この手の手順書は使うときには必ず行方不明になっている。

設定ファイルを人間が編集しない

dnsmasq 側も Caddy 側も、変わる部分は全部 CLI が生成している。Caddyfile はテンプレートのプレースホルダを、登録済みアプリ全件から作ったブロックで置き換えて出力する。生成先は /srv/caddy/Caddyfile で、パッケージが読む /etc/caddy/Caddyfile 側にはそれを import する1行しか書いていない。生成物の所有権は、CLI を動かすユーザーが書けて caddy グループが読める形にしてある。TLS のデータディレクトリのほうは Caddy 自身が書くので所有者が違う。

生成には2つ、意図して入れた性質がある。1つはアプリ名でソートしてから並べていることで、論理的に同じ状態からは必ず同じバイト列が出る。もう1つは、生成した内容がディスク上のものと一致していれば書き込まないようにしてあること。おかげで、サービスの再起動のたびに意味のない reload が走ることがない。

反映のさせ方は途中で1回変えている。systemctl reload で叩いていたのを、caddy reload --config を直接呼ぶ形にした。このとき、systemd の unit override で TLS データの置き場所を /srv/ 配下へ移してあるので、CLI から呼ぶ側にも同じ環境変数を明示的に渡している。常駐している側と手で呼ぶ側で、同じディレクトリを見ていることを揃える 必要がある。

生成に寄せると、入力の検証が1か所に集まる。ドメイン名は --domain フラグで外から渡ってきて、dnsmasq の設定ファイルと Caddyfile の両方に、エスケープなしの文字列として埋め込まれる。だから受け取った時点で弾く。

ドメイン名の検証(CLI)
// ドメイン名は dnsmasq.conf と Caddyfile の両方に、
// エスケープなしの文字列としてそのまま埋め込まれる。
for _, r := range []string{"/", "\n", "\r", " ", "\t", "{", "}"} {
	if strings.Contains(domain, r) {
		return fmt.Errorf("domain contains invalid character %q: %s", r, domain)
	}
}

/ と改行は address=/domain/ip の形式そのものを壊せるし、{ } は Caddyfile のブロック構造を壊せる。長さの上限(253 文字、ラベルごとに 63 文字)とドットを含むことも同じ場所で見ている。使い回すつもりのない自宅の CLI でも、生成される設定ファイルの文法が、そのまま入力仕様になっている ことは変わらない。

まとめ

LAN の中で「アプリ名を打てば HTTPS で開く」ようにするのは、部品としては dnsmasq 1つと Caddy 1つで足りる。実装で迷ったところは1つもない。判断が要ったのは、どこまでを自分のものだと主張するかだけだった。

ワイルドカードを1行張るのは、実装としては最小で、影響としては最大になる。手を動かす量と、壊れたときに巻き込む範囲が逆を向いているので、楽なほうを選ぶと気づかないうちに大きいほうを選んでいる。自宅の LAN なので誰にも怒られないが、怒られないことと壊れていないことは別だ。

そして、消せない副作用は消せないまま残した。名前が衝突すれば実在のサイトが1つ見えなくなる。この構成を使う限りそれは無くならないので、直せるふりをせず、制約として書いてある。設計の記録として残すべきなのは、解決したことよりも、解決しないと決めたことのほうだと思っている。

dnsmasqCaddyDNSTLS