はじめに
自宅や小規模拠点同士でLAN間VPNを張りたいが、両拠点とも固定IPが無い,しかもCGN(Carrier Grade NAT)配下で1つのグローバルIPを複数契約者と共有しているという条件でSite-to-SiteのVPNを構築する記事です。
AWS EC2上に立てたHeadscale(Tailscaleの自己ホスト型コントロールサーバー)経由でLAN間接続した際の記録です。証明書やドメインを新たに取得せずに済ませる超ローコスト方針で進めたため,その過程で踏んだ地雷もそのまま記録しています。
対象読者は以下のような方を想定しています。
- 拠点間VPNを検討しているが,動的IPやCGNの制約に悩んでいる
- Tailscale/Headscaleを使ったことはないが,仕組みをざっくり理解したい
- OpenWrt 25.12系(apkベース)でTailscaleを動かしたい
前提知識として,TCP/IPの基本,AWSの基本操作(EC2起動程度),LinuxやOpenWrtでのSSH/CLI操作に馴染みがあることを想定しています。
検証環境
本記事の内容は,以下のバージョンで検証しています。バージョンによって設定項目やコマンド体系が変わる部分があるため(特にHeadscaleとOpenWrtのパッケージ管理まわり),記事を参考にする際はご自身の環境のバージョンと照らし合わせてください。
| ソフトウェア/環境 | バージョン |
|---|---|
| EC2 AMI | Ubuntu Server 24.04 LTS(Arm64) |
| Docker | 29.1.3 |
| Headscale | v0.29.3 |
| OpenWrt | 25.12.5(r33051-f5dae5ece4) |
| OpenWrtターゲット | mvebu/cortexa9 |
| Tailscale(クライアント) | v1.98.3-1 |
| OpenSSL | 3.0.13(EC2側,証明書生成に使用) |
背景・要件整理
今回の要件は次の通りです。
- 拠点は日本国内2箇所
- 拠点A(OCNバーチャルコネクト回線,IPv4+IPv6),拠点B(楽天モバイル回線,IPv4+IPv6)
- 両拠点とも非固定IPv4アドレスでしかもCGN配下(同一グローバルIPを複数契約者で共有)
CGN配下だと,拠点のルーターは自分専用のグローバルIPを持たないため,外部から拠点へのインバウンド接続が成立しません。これは,IPsecやL2TPで直接サイト間トンネルを張る従来型の構成が使えないことを意味します。
つまり,「どちらかが待ち受け役になる」という構成そのものが組めないので,固定グローバルIPを持つ中継点(今回はAWS EC2)を用意し,両拠点からアウトバウンドで接続する構成です。
方式比較
中継方式を採用するとして,実装方法はいくつか候補がありました。
AWS Site-to-Site VPN
Customer Gateway Deviceの外部インターフェースIPは通常静的である必要がありますが,ACM Private CAの証明書を使えば動的IPにも対応できるという機能があります。ただしこれは「固定ではないが到達可能なグローバルIPを持つ拠点」向けの機能であり,そもそもグローバルIPを持たないCGN配下の拠点では無理です。加えてACM Private CAは月額固定費が別途発生し,小規模用途には見合いません。今回は不採用です。
AWS Client VPN
拠点側からのアウトバウンド接続が基本形なので,CGN配下でも接続自体は可能です。ただし課金モデルがエンドポイント関連付けと接続時間の両方に発生する従量課金で,24時間365日の常時接続用途では非常に割高になります(試算では月2万円超)。そして据え置き型の拠点間接続には向いていません。
VPS(EC2)+ 素のWireGuard
固定IPを持つVPS/EC2をハブにして,両拠点からWireGuardで接続する構成です。コストは安く済みますが,素のWireGuardにはNAT越え(hole punching)の仕組みが無いため,ペイロード通信が常にVPS経由になります。P2P接続にはならず,VPSの帯域がボトルネックになり得ます。
Tailscale(SaaS版)
WireGuardをベースに,NAT越えの自動化,DERPリレーへの自動フォールバックを備えたメッシュ型VPNです。導入は非常に楽ですが,コントロールプレーンがTailscale社サービスに依存します。
Headscale(OSS版)
Tailscaleのコントロールサーバー部分をセルフホストできるOSS実装です。クライアント側はTailscale純正のクライアントをそのまま使えるため,NAT越え・DERPフォールバックの恩恵はそのままに,制御情報を自社管理下に置けます。
今回はこのHeadscale + Tailscaleクライアント構成を採用しました。中継点にはEC2(大阪リージョン)を使います。このサーバで将来的にDERPも兼用できるはず。
全体構成
構成は以下の通りです。
拠点A(OCN, OpenWrt) 拠点B(楽天モバイル, OpenWrt)
LAN: 192.168.101.0/24 LAN: 192.168.100.0/24
192.168.102.0/24
│ │
└──── Tailscaleクライアント ─────────┘
│
(制御情報のみ)
│
EC2 (大阪リージョン, t4g.nano)
Headscale(Dockerコンテナ) + 自己署名CA
採用パラメータは以下の通りです。
| 項目 | 値 |
|---|---|
| リージョン | ap-northeast-3(大阪) |
| インスタンスタイプ | t4g.nano(ARM/Graviton) |
| OS | Ubuntu Server 24.04 LTS(Arm64) |
| Headscaleバージョン | v0.29.3 |
| TailScaleクライアント | OpenWrt 25.12.5 |
EC2環境構築
インスタンスタイプの選定
大阪リージョンのオンデマンド料金を比較すると,t4g.nano(ARM/Graviton)が最安でした。Headscale程度の軽量ワークロードには十分なスペックです。
| インスタンス | 時間単価 | 月額目安(730h) |
|---|---|---|
| t4g.nano | $0.0054 | 約$3.94 |
| t3.nano | $0.0068 | 約$4.96 |
| t2.nano | $0.0076 | 約$5.55 |
Elastic IPについて(今回は未使用)
本来,EC2のパブリックIPは再起動(stop→start)のたびに変わるため,恒久運用ではElastic IP(EIP)を取得してインスタンスに固定する方が安定します。AWS自動生成のFQDN(ec2-<IP>.<region>.compute.amazonaws.com)もIPに連動する形式なので,EIPを固定すればFQDNも固定できます。
ただし今回はテスト運用という位置づけだったため,EIPは取得せず,起動時に割り当てられたFQDNをそのまま使う方針にしました。stop→startでIPが変わればFQDNも変わりますが,reboot(単純な再起動)であれば変わりません。本格的に運用するならEIPで。
IAMロールとSession Manager
セキュリティグループ付きでもSSHは使いたくないので,AWS SSM経由での接続としました。この場合,セキュリティグループにSSH(22番)の受信ルールを一切追加する必要がありません。
事前にIAMロールを作成し,AmazonSSMManagedInstanceCoreポリシーをアタッチした上で,EC2インスタンスのIAMインスタンスプロファイルとして指定します。
セキュリティグループのインバウンドルールは以下の通りとしました。VPN用途のポートのみで,管理用の入口は一切開けていません。
| タイプ | プロトコル | ポート | ソース |
|---|---|---|---|
| カスタムTCP | TCP | 443 | OCNのIPoEなIP(/32),拠点B(楽天モバイルのCIDR) |
| カスタムUDP | UDP | 41641 | 同上 |
| カスタムUDP | UDP | 3478 | 同上(自前DERP用に確保) |
| カスタムTCP | TCP | 80 | 0.0.0.0/0(証明書取得試行時のみ一時的に開放,後述、結局消した) |
拠点Bは楽天モバイルの回線で,CGNのIPレンジがかなり広く変動します。個別の/32指定は非現実的なので,133.106.0.0/16という大まかなCIDRで許可しました。0.0.0.0/0よりは絞り込めているという判断です。
OCNバーチャルコネクトで取得できるIPv4アドレスは変動が少ないので/32で。
Docker導入
sudo apt update && sudo apt upgrade -y
sudo apt install -y docker.io
sudo systemctl enable --now docker
Headscaleサーバー構築
config.yamlの要点
最近のHeadscale(v0.29系)は必須項目が増えており,最小構成で書くとすぐに起動エラーになります。今回詰まったポイントを反映した最終形は以下の通りです。
server_url: https://ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com
listen_addr: 0.0.0.0:443
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
tls_cert_path: /etc/headscale/tls/server.crt
tls_key_path: /etc/headscale/tls/server.key
log:
level: info
dns:
magic_dns: true
base_domain: headscale.local
override_local_dns: false
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24h
ポイントは以下の3つです。
-
noise.private_key_pathが無いと,Tailscale v2プロトコル対応のため起動時エラーになる -
prefixes(Tailnet内部でクライアントに割り振るIPアドレス帯)を明示しないと起動しない。100.64.0.0/10はTailscale公式のデフォルトと同じCGNAT予約帯(RFC 6598)で,拠点のLANとは重複しない -
derpセクションが空だと「DERPMap is empty」で起動できない。自前DERPを使わない場合でも,公式のパブリックDERPマップURLを指定しておく必要がある
証明書の壁
当初は,追加コストなしで済ませたいという理由から,AWS自動生成FQDN(ec2-xx-xxx-xxx-xxx.<region>.compute.amazonaws.com)のまま,Let's EncryptでTLS証明書を取得しようとしました。
tls_letsencrypt_hostname: ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: :80
セキュリティグループの80番ポートを一時的に開放し,HTTP-01チャレンジを試みたところ,以下のエラーで弾かれました。
acme request returned error body="{
\"type\": \"urn:ietf:params:acme:error:rejectedIdentifier\",
\"detail\": \"Error creating new order :: Cannot issue for \\\"ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com\\\": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy\",
\"status\": 400
}" status_code=400
Let's Encryptは,amazonaws.com配下のようなクラウドプロバイダーの自動割当ドメインへの証明書発行を,ポリシーで拒否しています。HTTP-01/DNS-01といったチャレンジ方式以前の話で,このドメイン名自体が発行対象外だったということです。
独自ドメインを取得すれば当然解決しますが,今回はテスト運用の位置づけで追加コストや手間を増やしたくなかったため,みんな大好きオレオレ証明書(自己署名証明書)に方針転換しました。
自己署名CA・サーバー証明書の作成
mkdir -p ~/headscale/config/tls
cd ~/headscale/config/tls
# CA(認証局)の作成
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 9999 \
-out ca.crt \
-subj "/CN=Headscale Test CA"
# サーバー証明書の作成
openssl genrsa -out server.key 4096
openssl req -new -key server.key -out server.csr \
-subj "/CN=ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com"
# SAN(Subject Alternative Name)付きでCA署名
cat > server.ext << 'EOF'
subjectAltName = DNS:ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com
EOF
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 825 -sha256 -extfile server.ext
chmod 600 ca.key server.key
TailscaleクライアントはGoの標準TLSライブラリで検証を行うため,SAN(Subject Alternative Name)が無い証明書は拒否されます。ここは素のcurl等での確認だけでは気づきにくいポイントでした。
生成したserver.crt/server.keyをtls_cert_path/tls_key_pathとしてconfig.yamlに指定し,コンテナを起動します。
sudo docker run -d \
--name headscale \
--restart unless-stopped \
--network host \
-v ~/headscale/config:/etc/headscale \
-v ~/headscale/data:/var/lib/headscale \
headscale/headscale:latest \
serve
証明書が正しく反映されているかは以下で確認できます。
$ echo | openssl s_client -connect localhost:443 -servername ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com 2>/dev/null | openssl x509 -noout -issuer -subject -dates
issuer=CN = Headscale Test CA
subject=CN = ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com
notBefore=Aug 15 07:20:34 2026 GMT
notAfter=Jul 10 07:20:34 2048 GMT
OpenWrtクライアント設定
拠点側のルーターにはOpenWrt化したFortiGate52Eを使っています。
以降はOpenWrt 25.12.5(apkベース)前提で進めます。
パッケージ導入
apk update
apk add ca-certificates
apk add iptables-nft ip6tables-nft
apk add tailscale luci-app-tailscale-community
OpenWrt 25.12ではデフォルトでnftablesベースに移行しており,iptablesコマンド自体が標準では入っていません。Tailscaleはルーティング・ファイアウォールルールの操作にiptables(または互換レイヤー)を要求するため,iptables-nftパッケージの追加インストールが必須です。これが無いと,tailscaledの起動シーケンスで以下のように失敗します。
linuxfw: clear iptables: exec: "iptables": executable file not found in $PATH
自己署名CA証明書の配置
EC2側で作成したca.crtの中身を,拠点側に配置します。
cat > /etc/ssl/certs/headscale-ca.pem << 'EOF'
-----BEGIN CERTIFICATE-----
(ca.crtの中身)
-----END CERTIFICATE-----
EOF
ここで一つ注意点があります。OpenWrtのca-certificatesパッケージは,Debian/Ubuntu系でよくある「個別証明書を1本のバンドルファイルに統合する」方式(update-ca-certificatesコマンド)を採用していません。個別ファイル+ハッシュ名シンボリックリンクという構成が標準です。
$ apk info -L ca-certificates | head -5
etc/ssl/certs/002c0b4f.0
etc/ssl/certs/0179095f.0
etc/ssl/certs/062cdee6.0
...
追加のCA証明書も,個別ファイルとして/etc/ssl/certs/に置くだけで,Go言語のTLSライブラリ(Tailscaleが使用)がディレクトリ内のファイルを読み込む仕組みです。この点はハマりどころだったので後述の「失敗の学び」にも改めてまとめます。
Tailscaleサービスの起動
/etc/init.d/tailscale enable
/etc/init.d/tailscale start
/etc/init.d/tailscale status
Headscaleサーバーへの接続
拠点ごとに,LANサブネットを--advertise-routesにカンマ区切りで指定します。
tailscale up --login-server https://ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com \
--advertise-routes=192.168.101.0/24,192.168.102.0/24 \
--accept-routes \
--hostname=fg52e-siteA
実行すると認証用URLが表示されるので,ブラウザで開いて認証します(自己署名証明書のため警告が出ますが,テスト運用なので許容しました)。
To authenticate, visit:
https://ec2-xx-xxx-xxx-xxx.ap-northeast-3.compute.amazonaws.com/register/hskey-authreq-xxxxxxxxxxxx
EC2側でのノード登録・ルート承認
# ユーザー作成(初回のみ)
sudo docker exec headscale headscale users create default
# ノード登録
sudo docker exec headscale headscale auth register --auth-id hskey-authreq-xxxxxxxxxxxx --user default
# 登録確認
sudo docker exec headscale headscale nodes list
# 広告されたルートを確認
sudo docker exec headscale headscale nodes list-routes
# ルート承認
sudo docker exec headscale headscale nodes approve-routes -i <ノードID> -r 192.168.101.0/24,192.168.102.0/24
なお,--advertise-routesとapprove-routesは,どちらも「指定した内容で全体を上書きする」仕様です。後からサブネットを追加する場合は,既存のものも含めて全部を指定し直す必要があります。
ファイアウォールの追加設定
ここが一番時間を溶かした部分です。上記までの手順を終えても,tailscale ping(Tailscale独自のping)は通るのに,通常のping(ICMP)が通らないという状態になりました。
$ tailscale ping 100.64.0.2
pong from fg52e-sitea (100.64.0.2) via 153.181.**.***:8088 in 78ms
$ ping -c 4 100.64.0.2
PING 100.64.0.2 (100.64.0.2): 56 data bytes
--- 100.64.0.2 ping statistics ---
4 packets transmitted, 0 packets received, 100% packet loss
WireGuardトンネル自体は生きているのにICMPが通らない,という状態は,OpenWrtのファイアウォール(fw4/nftables)側でtailscale0インターフェースがどのゾーンにも属していないことが原因でした。詳しい経緯と解決方法は「失敗の学び」にまとめます。
結論としては,lanゾーンにtailscale0をdeviceとして追加する必要があります。
uci add_list firewall.@zone[0].device='tailscale0'
uci commit firewall
/etc/init.d/firewall restart
疎通確認・動作検証
両拠点のファイアウォール設定後,双方向で疎通を確認しました。
# 拠点B(楽天モバイル)から拠点AのLAN内端末へ
$ ping 192.168.101.254
PING 192.168.101.254 (192.168.101.254): 56 data bytes
64 bytes from 192.168.101.254: seq=0 ttl=64 time=66.843 ms
64 bytes from 192.168.101.254: seq=1 ttl=64 time=84.442 ms
...
--- 192.168.101.254 ping statistics ---
11 packets transmitted, 11 packets received, 0% packet loss
round-trip min/avg/max = 66.843/82.767/110.941 ms
# 拠点A(OCN)から拠点BのLAN側IPへ
$ ping -c 4 192.168.100.254
PING 192.168.100.254 (192.168.100.254): 56 data bytes
64 bytes from 192.168.100.254: seq=0 ttl=64 time=94.181 ms
64 bytes from 192.168.100.254: seq=1 ttl=64 time=71.670 ms
64 bytes from 192.168.100.254: seq=2 ttl=64 time=82.393 ms
64 bytes from 192.168.100.254: seq=3 ttl=64 time=72.012 ms
--- 192.168.100.254 ping statistics ---
4 packets transmitted, 4 packets received, 0% packet loss
round-trip min/avg/max = 71.670/80.064/94.181 ms
双方向とも0%packet lossで安定して疎通しました。
DERP経由かどうかの確認
今回の構成では,EC2の帯域を経由せず拠点間で直接パケットをやり取りできているかが重要な検証ポイントでした。tailscale statusの出力で確認できます。
$ tailscale status
100.64.0.2 fg52e-sitea default linux -
100.64.0.1 fg52e-siteb default linux active; direct 133.106.**.**:43948, tx 38732 rx 35404
active; direct [IP]:[port]という表示が,DERPリレーを介さないP2P直接接続を示しています。DERP経由の場合はrelay "region"という表示になります。
tailscale pingでも同様に確認できます。
$ tailscale ping 100.64.0.1
pong from fg52e-sitea (100.64.0.2) via 153.181.**.**:8088 in 78ms
via [実IPアドレス]:[ポート]と表示されていれば直接経路,via DERP(region)と表示されればリレー経由です。
興味深かったのは,接続確立直後はIPv6アドレス(240b:c020:...)経由のdirect接続だったのが,OPenWRTでIPv6を無効にするとIPv4アドレス経由のdirect接続に切り替わっていたことです。Tailscaleは利用可能な経路の中から動的に最適なものを選択しており,CGN環境であってもIPv4でのhole punchingが実際に成立することを確認できました。
セキュリティに関する考察
この構成を組んでいて気になったのが,「HeadscaleサーバーのFQDNが漏れたら,誰でもVPNに参加できてしまうのでは」という点です。結論から言うと,FQDNの秘匿性そのものには依存していません。実質的な関所になっているのは,サーバー側での手動承認です。
拠点でtailscale up --login-server https://<FQDN> ...を実行しても,その時点ではまだTailnetに参加できません。表示される認証URLをブラウザで開いても,「Node registration」という画面と,サーバー側で叩くべきコマンドが表示されるだけです。
headscale auth register --auth-id <auth-id> --user <username>
このコマンドをサーバー管理者(自分)が手動で実行するまで,そのノードはいつまでも未登録のままです。つまり,FQDNを知っているだけでは何もできず,サーバー側で明示的に承認して初めてTailnetに参加できる,という2段階の関所になっています。
セキュリティグループでのソース制限(拠点固定IPや大まかなCIDR)は最初の足切りとしては有効ですが,拠点Bのように広いCGNレンジを許可している場合,同じキャリアの無関係な利用者からもHeadscaleサーバーへの到達自体は可能です。最終的にネットワークへの参加を防いでいるのは,この手動承認のステップです。
見に覚えのないノードに気づいたら
定期的にnodes listで登録済みノードを確認する習慣をつけておくと安心です。
sudo docker exec headscale headscale nodes list
もし見覚えのないホスト名や,承認した記憶のないノードがonlineで紛れ込んでいた場合は,以下の手順で対処します。
# 該当ノードのIDを確認(nodes listのID列)
sudo docker exec headscale headscale nodes list
# 即座にログアウトさせる(セッションを無効化)
sudo docker exec headscale headscale nodes expire -i <ノードID>
# 完全に削除する(再度の登録は不可,同じキーでの復帰もできない)
# docker exec経由だと確認プロンプトに答えられないため --force が必要
sudo docker exec headscale headscale nodes delete -i <ノードID> --force
expireはセッションを切るだけで登録情報自体は残るのに対し,deleteはTailnetから完全に除籍する操作です。不審なノードだと判断した場合はdeleteまで行うのが無難です。
なお,今回の構成では全ノードをdefaultという単一ユーザー配下に登録しています。Headscaleは,ACL(アクセス制御ポリシー)を明示的に設定しない限り,同じTailnet内のノード同士は自由に通信できるというデフォルト挙動です。誤って怪しいノードを承認してしまうと,承認されたルートの範囲で拠点のLANに到達できてしまうため,承認は都度慎重に行う必要があります。
コストまとめ
大阪リージョン(ap-northeast-3),1ドル160円換算での月額試算です。
| 項目 | 月額(USD) | 月額(円) |
|---|---|---|
| EC2インスタンス(t4g.nano) | $3.94 | 約630円 |
| EBS(gp3, 8GB) | $0.77 | 約123円 |
| 合計(EIP無し,今回の構成) | 約$4.71 | 約753円 |
| Elastic IP(使用中,参考) | $3.65 | 約584円 |
| 合計(EIP有り,運用フェーズ想定) | 約$8.36 | 約1,338円 |
2024年2月以降,AWSでは使用中のパブリックIPv4アドレス(自動割当・EIP問わず)にも課金が発生する仕様になっているため,EIPを使うかどうかで月500円強の差が出ます。テスト運用中はEIP無しで様子を見て,本運用に切り替えるタイミングで追加する方針としました。
今後の課題
- 独自ドメイン + Let's Encrypt化:自己署名証明書は動いているものの,拠点が増えるたびにCA証明書を個別配布する手間が発生します。独自ドメイン(年千円程度)を取得すれば,この手間は解消できます
- EIPの取得:本運用に乗せる際は,FQDN固定のためEIPが必須になります
- 自前DERPの検討:現状は公式のパブリックDERPを設定上参照していますが,実測ではP2P接続が成立しているためDERPを使っていません。将来的にhole punchingが失敗する環境が出てきた場合に備え,自前DERPの用意も選択肢です
確認コマンド集
EC2/Headscale側
# ノード一覧
sudo docker exec headscale headscale nodes list
# ルート一覧(広告・承認状況)
sudo docker exec headscale headscale nodes list-routes
# ルート承認
sudo docker exec headscale headscale nodes approve-routes -i <ID> -r <CIDR1>,<CIDR2>,...
# ユーザー作成
sudo docker exec headscale headscale users create <username>
# ノード登録
sudo docker exec headscale headscale auth register --auth-id <auth-id> --user <username>
# 証明書の有効期限確認
echo | openssl s_client -connect localhost:443 -servername <FQDN> 2>/dev/null | openssl x509 -noout -dates -issuer
# Headscaleコンテナのログ確認
sudo docker logs -f headscale
OpenWrt(拠点)側
# サービス状態確認
/etc/init.d/tailscale status
# 接続状態(direct/relay確認)
tailscale status
# WireGuardレベルでの疎通確認
tailscale ping <相手のTailscale IP>
# 通常のICMP疎通確認
ping -c 4 <相手のTailscale IPまたはLAN内IP>
# CA証明書の配置確認
ls -la /etc/ssl/certs/headscale-ca.pem
# ファイアウォールへのtailscale0登録確認
uci show firewall.@zone[0]
# nftablesへの反映確認
nft list ruleset | grep -A 8 "chain input {"
# 自ノードのグローバルIP確認
curl -4 ifconfig.me
失敗の学び
今回の作業で得られた,構成そのものに起因する学びをまとめます(単純なコマンドの打ち間違いは対象外とします)。
Let's EncryptはAWS自動生成FQDNには発行してもらえない
*.compute.amazonaws.comのような,クラウドプロバイダーが自動割当するドメインに対して,Let's Encryptはポリシーとして証明書発行を拒否します。HTTP-01/DNS-01といったチャレンジ方式の選択以前の話であり,80番ポートの開放有無に関わらず解決しません。無料でTLSを済ませたい場合,独自ドメインの取得は避けて通れないポイントです。
OpenWrtのca-certificatesは「個別ファイル方式」
Debian/Ubuntu系でおなじみのupdate-ca-certificatesコマンドや,cat *.pem > ca-certificates.crtで1本のバンドルファイルにまとめる運用は,OpenWrtのapk版ca-certificatesパッケージには存在しません。個別のCA証明書ファイル(XXXXXXXX.0やXXX_Root_CA.crt)がそのまま/etc/ssl/certs/に並ぶ方式です。
この違いに気づかずcat /etc/ssl/certs/*.pem > /etc/ssl/certs/ca-certificates.crtを実行してしまうと,元々存在しない単一バンドルファイルが新規作成されるだけでなく,その過程で他の正規CA証明書一式を巻き込んでしまうリスクがある点は要注意です。追加のCA証明書は,個別ファイルとして配置するだけで十分です。
iptablesコマンドは標準では入っていない
OpenWrt 25.12はデフォルトでnftablesベースに移行しており,iptablesコマンド自体がイメージに含まれていません。Tailscaleはルーティング制御にiptables(または互換レイヤー)を要求するため,iptables-nft・ip6tables-nftパッケージの追加インストールが必須です。無いと,tailscaledがルール設定に失敗し,起動シーケンスが完走しません。
Headscaleの必須設定項目が増えている
v0.29系のHeadscaleでは,最小構成のconfig.yamlだと以下がいずれも起動エラーになります。
-
noise.private_key_path未指定 -
prefixes(Tailnet内部IPアドレス帯)未指定 -
derpセクションが空(DERPMapが1件も無い)
いずれもエラーメッセージに手がかりが出るため,ログを見ながら1つずつ埋めていく形になりました。
ファイアウォールのnetwork指定とdevice指定は別物
OpenWrtのUCIファイアウォール設定で,ゾーンに何かを追加する際,networkとdeviceという2種類の項目があります。
-
network:/etc/config/networkに登録された論理インターフェース名(lanなど)を指定する項目 -
device:カーネル上の生デバイス名を直接指定する項目
Tailscaleが作るtailscale0は,OpenWrtのネットワーク設定に登録された論理インターフェースではなく,動的に生成される生デバイスです。そのため,uci add_list firewall.@zone[0].network='tailscale0'のようにnetwork側に追加しても,実際のnftablesルールには一切反映されません。
# 誤り(反映されない)
uci add_list firewall.@zone[0].network='tailscale0'
# 正しい
uci add_list firewall.@zone[0].device='tailscale0'
この違いに気づかないと,「WireGuardトンネル自体は確立している(tailscale pingは通る)のに,通常のICMPやTCP/UDP通信が一切通らない」という,一見矛盾した現象に遭遇します。LuCI(Web UI)の「Covered networks」という項目名も,見た目上はnetwork側の設定を指しており紛らわしいため,GUIで解決を試みる場合は注意が必要です。反映確認はnft list rulesetで,input/forwardチェーンにtailscale0向けの処理が実際に含まれているかを見るのが確実でした。
$ nft list ruleset | grep -A 8 "chain input {"
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept comment "!fw4: Accept traffic from loopback"
ct state vmap { established : accept, related : accept } comment "!fw4: Handle inbound flows"
tcp flags & (fin | syn | rst | ack) == syn jump syn_flood comment "!fw4: Rate limit TCP syn packets"
iifname { "br-lan", "tailscale0" } jump input_lan comment "!fw4: Handle lan IPv4/IPv6 input traffic"
iifname "br-wan" jump input_wan comment "!fw4: Handle wan IPv4/IPv6 input traffic"
jump handle_reject
}
参考リンク
- FortiGate 52EへのOpenWrt導入手順:(https://qiita.com/zeroichi_tecto/items/3f7266ed88fafc55bdc4)
- Headscale公式ドキュメント
- Tailscale公式サイト