Cloudflare Zero TrustでSSHを保護する

背景

サーバにSSHでアクセスするには、通常22番ポートをインターネットに公開する必要がある。しかし公開されたSSHポートは常に攻撃の標的になる。

Cloudflare Zero Trustを使えば、SSHポートを閉じたまま、認証済みのユーザーだけがSSH接続できる環境を構築できる。

Zero Trustとは

従来のセキュリティモデルでは「社内ネットワーク内は信頼する」という前提があった。Zero Trustはこの前提を捨て、全てのアクセスを検証するモデル。

【従来】
インターネット → ファイアウォール → 社内(信頼済み)
  ※ 一度入れば自由にアクセス可能

【Zero Trust】
インターネット → Cloudflare(認証・認可) → サーバ
  ※ 毎回アクセスを検証する
  ※ 認証されていなければ接続不可

仕組み

SSHの場合、Cloudflare TunnelとAccess機能を組み合わせる。

開発者のPC
  └── ssh コマンド
      └── cloudflared(ProxyCommand)
          └── Cloudflare(Access認証)
              └── Tunnel
                  └── サーバのSSHデーモン
  1. SSHコマンドを実行すると、cloudflaredがプロキシとして動作
  2. Cloudflare Accessが認証を要求(ブラウザでメール認証)
  3. 認証成功後、Tunnel経由でサーバのSSHに接続
  4. サーバ側のSSHポートは閉じたまま

前提条件

  • Cloudflare Tunnelが設定済みであること(Cloudflare Tunnelの設定方法を参照)
  • ローカルPCにcloudflaredがインストール済みであること

手順

1. Tunnel IngressにSSHを追加

Cloudflare APIでSSHのルーティングを追加する:

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "config": {
      "ingress": [
        {"hostname": "ssh-cf.example.jp", "service": "ssh://host.docker.internal:22"},
        {"service": "http_status:404"}
      ]
    }
  }'

cloudflaredがDockerコンテナで動作している場合、host.docker.internalでホストのSSHデーモンにアクセスする。docker-compose.ymlにextra_hostsの設定が必要:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    extra_hosts:
      - host.docker.internal:host-gateway

2. DNSレコードの追加

cloudflared tunnel route dns my-tunnel ssh-cf.example.jp

3. Access Applicationの作成

Cloudflare APIでSSH用のAccessアプリケーションを作成する:

curl -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/access/apps" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "My Server SSH",
    "domain": "ssh-cf.example.jp",
    "type": "ssh",
    "session_duration": "24h"
  }'

type: "ssh"が重要。これによりCloudflareがSSHプロキシとして動作する。

4. アクセスポリシーの設定

誰がSSHできるかを制御する。メールアドレスで制限する例:

curl -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/access/apps/<APP_ID>/policies" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "Allow admin",
    "decision": "allow",
    "include": [
      {"email": {"email": "admin@example.com"}}
    ]
  }'

他にも以下のような条件で制限可能:

  • メールドメイン(@example.ac.jpの全員)
  • IPアドレス範囲
  • IdP(Google Workspace、GitHub等)のグループ

5. ローカルPCのSSH設定

~/.ssh/configに以下を追加:

Host my-server-cf
  HostName ssh-cf.example.jp
  User myuser
  IdentityFile ~/.ssh/my_key
  ProxyCommand cloudflared access ssh --hostname %h

6. 接続

ssh my-server-cf

初回はブラウザが開き、メールアドレスの認証が求められる。認証後、トークンがローカルにキャッシュされ、session_duration(24時間)の間は再認証なしで接続できる。

7. SSHポートの閉鎖

Zero Trust SSH経由で接続できることを確認したら、サーバのファイアウォールまたはセキュリティグループで22番ポートを閉じる。

従来のSSHとの比較

項目通常のSSHZero Trust SSH
ポート開放22番が必要不要
認証鍵認証のみ鍵認証 + Cloudflare Access
ブルートフォースリスクあり攻撃経路なし
アクセス制御IP制限程度メール・IdP・国など柔軟
監査ログサーバのみCloudflare側にも記録
VPN場合により必要不要

トラブルシューティング

Empty app domainエラー

古いトークンキャッシュが原因。以下で解消:

rm -f ~/.cloudflared/*.lock ~/.cloudflared/*-token*

再度sshコマンドを実行してブラウザ認証をやり直す。

Connection timed out during banner exchange

cloudflaredコンテナからホストのSSHに到達できていない可能性がある。

  • extra_hostsが設定されているか確認
  • docker-compose.ymlで--protocol http2を指定(UDPが制限されている環境ではQUIC接続が失敗するため)
  • コンテナからホストへの接続テスト:docker run --rm --add-host host.docker.internal:host-gateway alpine sh -c "nc -zv host.docker.internal 22"(alpineにはncがデフォルトで含まれている)

QUICエラー

Failed to dial a quic connection

サーバのネットワークがUDPを制限している場合に発生する。cloudflaredの起動コマンドに--protocol http2を追加:

command: tunnel --protocol http2 run

まとめ

Cloudflare Zero Trust SSHを使うことで、SSHポートを完全に閉じた状態でサーバに安全に接続できる。鍵認証に加えてCloudflare Accessの認証が加わるため、多層防御が実現する。万が一SSHの鍵が漏洩しても、Cloudflare Accessの認証を通過しなければ接続できない。