アクセス元に応じてCloudflare Tunnel or Directな経路を通す
背景
自鯖Webアプリに出先からアクセスするためにCloudflare Tunnel(プロキシ) + mTLSの構成を取っていた。 LAN内にいるときはプライベートIPなどを直接指定してもアクセスできるのだが、URLを都度変えるのが面倒なのでCloudflare Tunnel経由でアクセスしていた。
ただCloudflare Tunnel経由だといくつかネガティブな点があるためLAN内では直接アクセスしたくなった。
主なモチベーションは以下の2点。
- Cloudflare Tunnel + mTLSは証明書の設定がされていても確率的にエラーになる問題に悩まされていたので、せめてLAN内ではストレスから解放されたい*1*2
- インターネット(Cloudflare Tunnel)を経由すると写真の同期が遅くなったり、最大100MBまでしか通信できないなどの制約があるのを解消したい*3
最終的に目指しているのは同じURLにアクセスしても、アクセス元に応じて適切な経路が透過的に選択される仕組み。
構成
元々のCloudflare Tunnelの設定は以下のような感じ。
ingress: - hostname: app1.example.com service: http://example1 - hostname: app2.example.com service: http://example2
外からアクセスする際は https://app1.example.com
でアクセスするため、LAN内でも同様にアクセスするためにSSL証明書を取る必要がある。
オレオレ証明書を作って各端末に配布するのは面倒だし、LE証明書の更新を自前構成で管理するのも嫌だったのでcaddyを使ってみることにした。
PVEでcaddy用のdebian LXCを作り、その中でdocker composeで起動させ、コンテナの管理はsystemdに任せる。 cloudflareでDNS-01 Challengeを行うので対応したイメージを利用。
services: app: image: ghcr.io/caddybuilds/caddy-cloudflare:2.9.1 restart: unless-stopped cap_add: - NET_ADMIN ports: - 80:80 - 443:443 - 443:443/udp volumes: - /etc/caddy/conf:/etc/caddy - /etc/caddy/site:/srv - caddy_data:/data - caddy_config:/config environment: - CLOUDFLARE_API_TOKEN=XXX volumes: caddy_data: caddy_config:
Caddyfile
{ acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} } app1.example.com { reverse_proxy http://example1 } app2.example.com { reverse_proxy http://example2 }
LAN内のDNSについてはOpenWrtで設定した。LAN内にDNSサーバーがあれば何でもよい。 普段はNextDNSを使っているため、家のWi-Fiの接続設定->DNS->手動設定で今回の用意したサーバーを使うようにした。
config domain option name 'app1.example.com' option ip 'caddyを動かしているプライベートIP' config domain option name 'app2.example.com' option ip 'caddyを動かしているプライベートIP'
Cloudflareのトークン指定とcaddyの設定がうまく動作していればcaddy起動時に証明書を取得してくれる。
ERR_SSL_PROTOCOL_ERROR の解消
Chromeでアクセスすると確率的に ERR_SSL_PROTOCOL_ERROR
が表示され、アクセスできない問題に遭遇した。
この問題は2025/3時点でChrome系のブラウザだけで確認しており、FirefoxやSafariなどでは起きないという特徴もあった。
調べたところ、Cloudflare(プロキシ)ではデフォルトでTLSv1.3が使われており、かつECHの利用もデフォルトで有効化されているためだった。 あんまり調べていないがECHに対応しているのが現時点ではChromeだけなのでFirefoxやSafariで問題が起きなかったのだと思う。 ECHの影響であるという切り分けにはChromeのNetLogを利用した。
ECHを無効化するにはTLSv1.2を使うか、(フリープランなので)APIを叩いてECHを無効化するかの2択だったが、TLSv1.2を使い続けても時間の問題になりそうなのでcliでECHを無効化することにした。
# 実行前 $ > curl https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/ech \ -H "X-Auth-Email: $EMAIL" \ -H "X-Auth-Key: $CLOUD_FLARE_GLOBAL_API_KEY" \ {"result":{"id":"ech","value":"on","modified_on":null,"editable":true},"success":true,"errors":[],"messages":[]} # ECH無効化 $ > curl -XPATCH https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/ech \ -H "X-Auth-Email: $EMAIL" \ -H "X-Auth-Key: $CLOUD_FLARE_GLOBAL_API_KEY" \ -H "Content-Type: application/json" --data '{"id":"ech","value":"off"}' # 実行後 $ > curl https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/ech \ -H "X-Auth-Email: $EMAIL" \ -H "X-Auth-Key: $CLOUD_FLARE_GLOBAL_API_KEY" \ {"result":{"id":"ech","value":"off","modified_on":null,"editable":true},"success":true,"errors":[],"messages":[]}
しばらくするとECHが返ってこなくなっていることを確認。
$ > curl "https://dns.google/resolve?name=your.domain.com&type=HTTPS" {"Status":0,...,ech=XXX ipv6hint=...} $ > curl "https://dns.google/resolve?name=your.domain.com&type=HTTPS" {"Status":0,..., ipv6hint=...}
また、caddyでもちょうど先週ECH対応が入り2.10系からサポートする*4と見かけたが恐らく本件の問題に直接関係はないので2.10系に乗り換えても問題は解決しないと思う。