はじめに

TailscaleはWireGuardベースのメッシュVPNを簡単に構築できる。 一方、通常はTailscale社のコントロールプレーンを利用する。 今回はオープンソースの Headscale をVPS上に構築し、端末の登録・管理を自分のサーバーで行う。

実際に構築した環境では、MacとiPhoneの接続に成功し、最初はDERPリレー経由だった通信が、その後IPv4・IPv6の直接P2P通信に切り替わった。

Headscaleは制御サーバーであり、通常の端末間データ通信を中継するVPNハブではない。直接接続できない場合はDERPリレーを利用する。なお、今回利用したDERPはHeadscale自身でホストしたものではない。

構成

項目内容
サーバーXserver VPS / Ubuntu 22.04.5
公開ドメインheadscale.ingenboy.com
Headscalev0.28.0
DockerDocker Engine + Compose v2
HTTPSNginx + Let’s Encrypt (Certbot)
macOS TailscaleHomebrew版 v1.104.4
端末MacBook Pro、iPhone
Tailnet IPMac 100.64.0.1、iPhone 100.64.0.2

通信経路は以下のとおり。

Mac / iPhone ── HTTPS ──> Nginx :443 ── HTTP ──> Headscale :8080
      │                                            (制御・端末管理)
      └──────── WireGuard P2P ──────── 相手の端末
                 └─ P2P不可ならDERPリレー

VPSでは既存のNginxが80/443番ポートを利用しているため、Headscaleコンテナの8080番ポートを ホストの127.0.0.1:18080 にだけ公開した。これならHeadscaleのHTTPポートをインターネットへ直接公開せずに済む。

1. DNSを設定

DNS管理画面で次のAレコードを登録する。

headscale.ingenboy.com  A  162.43.28.215

確認:

dig +short headscale.ingenboy.com A

2. Headscale用ディレクトリを作る

VPSで実行する。

sudo mkdir -p /opt/headscale/{config,lib,run}
cd /opt/headscale

Headscale v0.28.0に対応した公式の設定サンプルを取得する。設定項目はバージョンごとに変わるため、異なるバージョンのサンプルを流用しないこと。

sudo curl -fL \
  https://raw.githubusercontent.com/juanfont/headscale/v0.28.0/config-example.yaml \
  -o /opt/headscale/config/config.yaml

設定ファイルの主な変更箇所:

server_url: https://headscale.ingenboy.com
listen_addr: 0.0.0.0:8080

tls_cert_path: ""
tls_key_path: ""

server_url はクライアントから見えるHTTPS URL。listen_addr はコンテナ内のHTTP待ち受け先。TLSはNginxで終端するため、Headscale自身には証明書を設定しない。

重要: 公式設定ファイルにあるデータベースやノードIP範囲などの設定は維持する。今回の環境ではSQLiteのデータベースが /var/lib/headscale/db.sqlite に作成された。

3. Docker Composeを作成

/opt/headscale/docker-compose.yml:

services:
  headscale:
    image: docker.io/headscale/headscale:0.28.0
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./config:/etc/headscale:ro
      - ./lib:/var/lib/headscale
      - ./run:/var/run/headscale
    ports:
      - "127.0.0.1:18080:8080"

起動:

cd /opt/headscale
sudo docker compose up -d
sudo docker logs --tail=50 headscale

ヘルスチェック:

curl -i http://127.0.0.1:18080/health

正常なら次のように返る。

{"status":"pass"}

4. Nginxでリバースプロキシ

既存のNginxにHeadscale用のバーチャルホストを追加する。以下は構成例であり、実際のCertbot設定や既存のNginxファイル配置に合わせて調整する。

まずHTTPのサーバーブロックを用意し、Certbotで証明書を取得する。

server {
    listen 80;
    server_name headscale.ingenboy.com;

    location / {
        proxy_pass http://127.0.0.1:18080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d headscale.ingenboy.com

CertbotによるHTTPS設定後、443番のサーバーブロックにも上記のプロキシ設定が適用されていることを確認する。Headscaleの制御通信ではHTTP Upgradeを利用するため、Upgrade関連ヘッダーも重要。

curl -i https://headscale.ingenboy.com/health

今回の環境ではHTTP 200と {"status":"pass"} を確認できた。

Headscaleのログに Listening without TLS but ServerURL does not start with http:// と表示される場合がある。今回はHTTPSをNginxで終端する構成なので、この警告だけで異常とは判断しない。

5. Headscaleのユーザーを作成

VPSで実行:

sudo docker exec headscale headscale users create osaka

登録済みユーザーやノードは次のコマンドで確認できる。

sudo docker exec headscale headscale users list
sudo docker exec headscale headscale nodes list

6. MacにTailscaleをインストール

GUI版で遭遇した問題

最初は公式サイトからmacOSのGUIアプリをインストールした。しかし、Headscaleを指定してログインすると処理が進まなかった。

tailscale login --login-server=https://headscale.ingenboy.com

内部ログには次のエラーが出ていた。

fetch control key: Get "https://headscale.ingenboy.com/key?v=148":
failed to resolve "headscale.ingenboy.com":
no DNS fallback candidates remain

一方でmacOS標準のDNS解決とcurlは正常だった。

dscacheutil -q host -a name headscale.ingenboy.com
dig @1.1.1.1 headscale.ingenboy.com A +short
curl -v https://headscale.ingenboy.com/health

このため、今回の環境ではGUIアプリを終了し、Homebrew版のCLIとデーモンへ切り替えた。GUI版全般でHeadscaleが利用できないという意味ではなく、今回のMac固有の問題だった可能性がある。

Homebrew版の導入

brew install tailscale
sudo brew services start tailscale

確認:

which tailscale
tailscale version
pgrep -fl 'Tailscale|IPNExtension|tailscaled'

今回のHomebrew版CLIは /opt/homebrew/bin/tailscale、デーモンは /opt/homebrew/opt/tailscale/bin/tailscaled だった。

注意: GUI版とHomebrew版を同時に動かさない。今回、一時的に古いGUIアプリ向けのzsh関数が残り、tailscale コマンドがGUI版を呼び出していた。

which tailscale

これが実行ファイルのパスではなく、以下のような関数を表示する場合は、~/.zshrc に残った関数定義を削除する。

tailscale() {
  /Applications/Tailscale.app/Contents/MacOS/Tailscale "$@"
}

現在のシェルでは unfunction tailscale で解除できる。

今回のHomebrewデーモンが利用していたソケットは /var/run/tailscaled.socket だった。必要なら明示的に指定できる。

tailscale --socket=/var/run/tailscaled.socket status

Linuxでは

curl -fsSL https://tailscale.com/install.sh | sh
sudo systemctl start tailscaled
tailscale login --login-server=https://headscale.ingenboy.com

7. MacをHeadscaleに登録

Macで実行:

tailscale login --login-server=https://headscale.ingenboy.com

認証用URLが表示される。

To authenticate, visit:
    https://headscale.ingenboy.com/register/<登録用キー>

このキーは実際の出力から取得すること。ブログには本物の登録キーを掲載しない。

別ターミナルからVPSにSSH接続して登録する。

sudo docker exec headscale headscale nodes register \
  --user osaka \
  --key '<登録用キー>'

今回の環境では以下のメッセージが表示された。

Node rays-macbook-pro registered

Mac側で確認:

tailscale status
tailscale ip -4

登録後、Macには 100.64.0.1 が割り当てられた。iPhoneもTailscaleクライアントから同じHeadscaleサーバーへ登録し、100.64.0.2 が割り当てられた。iPhone側の操作画面やバージョンは本記事では省略する。

100.64.0.1  rays-macbook-pro  osaka   macOS  -
100.64.0.2  localhost        iphone  iOS    -

※ localhost はこのiPhoneノードに表示されたホスト名であり、ループバック通信を意味しない。

8. P2P通信を確認する

MacからiPhoneにTailscale pingを送る。

tailscale ping 100.64.0.2

最初はDERP経由だった。

pong from localhost (100.64.0.2) via DERP(tok) in 1.419s

その後、IPv4で直接接続された。

pong from localhost (100.64.0.2) via 49.252.86.226:6785 in 206ms

さらにIPv6での直接通信も確認できた。

pong from localhost (100.64.0.2) via [2400:2200:ca7:c017:c6d:a37c:e63a:6a1e]:41641 in 88ms

via DERP(...) はリレー経由、via IP:port は直接経路を示す。つまり、Headscaleは接続管理を行い、データ通信は端末同士で直接やり取りできた。

同じ家庭内LANに接続した場合

MacとiPhoneを同じLANに接続すると、一度はローカルIPアドレス経由で通信できた。

pong from localhost (100.64.0.2) via 192.168.0.16:41641 in 25ms

しかし、その後はDERP経由に戻り、以下の結果も発生した。

direct connection not established

同一LANであること自体が失敗の原因とは限らない。Wi-Fiのクライアント分離、iOSのVPN状態、ネットワーク切り替え時の経路探索など、追加の切り分けが必要。今回の検証では原因を特定していない。

おまけ

exit nodeの作り方(つまりp2pだけではなく、mesh内のノードのインターネット通信をこのexit nodeを経由させる方法)

exit node上で以下を打つ

sudo tailscale set --advertise-exit-node

head scaleで以下

docker exec headscale headscale nodes approve-routes \
  --identifier 5 \
  --routes 0.0.0.0/0

9. 運用・トラブルシューティング

Headscaleの稼働確認

sudo docker logs --tail=100 headscale
curl -i http://127.0.0.1:18080/health
curl -i https://headscale.ingenboy.com/health

Nginxの確認

sudo nginx -t
sudo nginx -T | grep -A 45 'server_name headscale.ingenboy.com'

端末一覧

sudo docker exec headscale headscale nodes list

クライアントの接続診断

tailscale status
tailscale netcheck
tailscale ping 100.64.0.2

バックアップ

Headscaleの状態を保持する /opt/headscale/lib と設定ファイル /opt/headscale/config/config.yaml を定期的にバックアップする。SQLiteの整合性を確保するため、稼働中のDBファイルを無造作にコピーするのではなく、SQLiteのバックアップ機能を利用するか、サービス停止中にバックアップする。

セキュリティ

  • HeadscaleのHTTPポートは 127.0.0.1 にのみバインドし、外部にはHTTPSの443番ポートだけを公開する。
  • 登録キーや秘密鍵を記事・ログ共有・Gitリポジトリに載せない。
  • HeadscaleとTailscaleのバージョン互換性を確認して更新する。ベータ版への更新は必要性を判断してから行う。
  • 登録済みノードやユーザーを定期的に確認し、不要になった端末は削除する。
  • Headscaleを自前でホストしても、デフォルトのDERPサーバーを利用する構成ではリレー通信に外部インフラが関与する。

セキュリティ:公開したHeadscaleに誰でも参加できるのか?

HeadscaleのURLをインターネットに公開していると、誰でもアクセスできるためVPNに勝手に参加されるのではないか、と気になる。しかし、Headscaleに到達できることと、VPNの端末として登録されることは別である。

今回の手動登録の流れ

  1. MacやiPhoneのTailscaleクライアントが、指定したHeadscaleへHTTPSで接続し、端末の登録を要求する。
  2. クライアントに https://headscale.example.com/register/<登録キー> のようなURLが表示される。
  3. Headscaleの管理者がVPSにログインし、登録キーと所属ユーザーを指定して承認する。
  4. 承認された端末がVPN内IPアドレスを取得し、Headscaleが配布するネットワーク情報に従って通信を開始する。

管理者側の操作例:

# 所属先のユーザーを作成(初回のみ)
docker exec headscale headscale users create osaka

# 登録待ち端末を承認する
docker exec headscale headscale nodes register \
  --user osaka \
  --key '<登録キー>'

# 登録済み端末を確認する
docker exec headscale headscale nodes list

この手動登録方式では、第三者が公開URLにアクセスして登録要求を送っただけでは、通常VPNに参加できない。管理者による承認が必要になる。

登録キーは公開鍵なのか?

登録URL末尾の文字列は、WireGuardの公開鍵そのものではなく、Headscaleが登録待ちの端末を特定するためのキーである。混同しやすい情報を整理すると、次のようになる。

情報役割扱い
端末の秘密鍵暗号通信や端末認証に必要な秘密情報端末内に保持し、公開しない
端末の公開鍵対応する秘密鍵を持つ端末の識別や暗号通信に利用相手に共有可能
登録キーHeadscaleで承認対象の登録要求を識別不用意に公開しない
事前認証キー(preauthkey)管理者の手動承認を省略して端末を登録するための認証情報秘密として管理する

osaka のようなHeadscaleのユーザー名も、単独でログインできるパスワードではない。登録した端末をどのユーザーに所属させるかを指定する名前である。

事前認証キーを使う場合は注意

Headscaleには、あらかじめ発行した**事前認証キー(preauthkey)**で端末を登録する方法もある。自動構築には便利だが、有効なキーが漏えいすると、設定された有効期限・使用回数などの条件内で第三者に端末を追加されるおそれがある。用途に応じて期限と再利用可否を制限し、GitHubやブログには掲載しない。

実際に守るべきもの

  • VPSのSSH・管理者権限: 奪われると端末の承認、設定変更などを行われる危険がある。SSH鍵認証、更新、アクセス制限を検討する。
  • HeadscaleのAPIキー・事前認証キー: 発行した場合は秘密情報として保管し、不要になったら失効させる。
  • 登録済み端末: MacやiPhone自体が侵害されると、その端末の権限でVPNを利用される可能性がある。
  • Headscaleとリバースプロキシ: 公開サービスなので脆弱性対策、HTTPS、適切なログ監視が必要。公開しているだけで安全が保証されるわけではない。

なお、端末を登録できるかと、登録済み端末がどこへ通信できるかも別問題である。後者はHeadscaleのACLポリシーなどで制御する。登録を許可した端末が、必ずしも全端末・全サービスにアクセスできる設計にする必要はない。

要するに、今回の構成では公開URLへのアクセスは自由でも、VPNへの参加には管理者の承認が必要という仕組みになっている。ただし、事前認証キーや管理権限の漏えい、ソフトウェアの脆弱性といった別の攻撃経路には引き続き注意する。

まとめ

Docker Compose、Nginx、Let’s Encrypt、Headscaleを組み合わせることで、自前の管理サーバーを持つTailscale Mesh VPNを構築できた。

今回の実測では、DERPリレー → IPv4直接接続 → IPv6直接接続という経路の変化を確認できた。VPNハブを経由させずに端末同士が通信できる点が、この構成の大きな魅力だ。

一方、macOS GUI版のログイン不調や同一LANでのP2P経路の不安定さなど、クライアント環境によっては追加の調査が必要になることも分かった。

今後はACLポリシー、サブネットルーター、Exit Node、DERPの自前運用なども検証してみたい。