839の日記

趣味の話を書くブログです。

アクセス元に応じて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にアクセスしても、アクセス元に応じて適切な経路が透過的に選択される仕組み。

  • LAN外からのアクセスの場合:Cloudflare TunnelとmTLSを経由して自鯖Webアプリへ接続
  • LAN内からのアクセスの場合:直接自鯖Webアプリへ接続

構成

元々の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系のブラウザだけで確認しており、FirefoxSafariなどでは起きないという特徴もあった。

調べたところ、Cloudflare(プロキシ)ではデフォルトでTLSv1.3が使われており、かつECHの利用もデフォルトで有効化されているためだった。 あんまり調べていないがECHに対応しているのが現時点ではChromeだけなのでFirefoxSafariで問題が起きなかったのだと思う。 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系に乗り換えても問題は解決しないと思う。

参考

*1:iPhoneの場合、プライベートウィンドウで開くことで高確率でこの問題を回避できる

*2:AndroidChromeやPCでは比較的起きにくい気がする

*3:obsidianのlivesyncサーバーをsync mode: livesyncで運用していて結構な頻度でアクセスしているのもあり、LAN内で解決できるならそうしたい

*4:https://github.com/caddyserver/caddy/issues/4221#issuecomment-2702361739