Cloudflare Origin Rules を使って WebSocket 通信を別オリジンにルーティングする

# 目的

WebSocket 通信だけ、バックエンドサーバが異なるため、オリジンを切り替えたいケースがあります。

今回は、Cloudflare の Origin Rules を使って WebSocket 通信を別オリジンにルーティングします。

# Origin Rules とは

- [Origin Rules · Cloudflare Rules docs](https://developers.cloudflare.com/rules/origin-rules/)

以下の図のような形で条件に当てはまる場合にいくつかの上書きアクションをおこなうことができます。

```mermaid
graph LR
subgraph Cloudflare
    B --> C{Origin Rules}
    C -->|Host header override| D[Request to origin]
    C -->|SNI override| D[Request to origin]
    C -->|DNS record override| D[Request to origin]
    C -->|Desitination port override| D[Request to origin]
  end
A[Request to edge] -->B(SSL Termination)
D --> E[Origin]
```

今回は、以下のような想定で切り替えられることを確認します。

```mermaid
graph LR
subgraph Cloudflare
    B --> C{Origin Rules}
    C -->|WebSocket 通信の場合</br>DNS record override| D[resolve.example.com</br>x.x.x.x]
  end
A[Request to edge] -->B(wscat.example.com</br>1.2.3.4)
D --> E[WebSocket</br>backend]
C -->|WebSocket 通信でない場合| F[Web backend]
```

# WebSocket サーバ準備

- [Cloudflare からの HTTP リクエストヘッダを可視化する - Qiita](https://qiita.com/khayama/items/0787728497918f2e68d6)

上記の通常 Web サーバに加えて、以下の設定を加えます。

WebSocket 通信のリバースプロキシができるように SELinux 設定を変更します。

```shell:
sudo /usr/sbin/setsebool -P httpd_can_network_connect 1
```

これを設定しないと以下のようなエラーが出ます。

`[Fri Jun 09 15:31:54.010523 2023] [proxy:error] [pid 940082:tid 140635314460416] (13)Permission denied: AH00957: HTTP: attempt to connect to 127.0.0.1:80 (localhost) failed
[Fri Jun 09 15:31:54.010582 2023] [proxy_http:error] [pid 940082:tid 140635314460416] [client 172.68.118.174:19386] AH01114: HTTP: failed to make connection to backend: localhost`

Cloudflare では 8443 での HTTPS 通信がサポートされているので、8443 ポートを使ってみます。

- [Network ports · Cloudflare Fundamentals docs](https://developers.cloudflare.com/fundamentals/get-started/reference/network-ports/#network-ports)

SSL 証明書を準備し、8443 ポートでは WebSocket 通信をリバースプロキシし、`localhost:8000` に通信します。

```apache:/etc/httpd/conf.d/ssl.conf 
# 追記
Listen 8443 https
<VirtualHost _default_:8443>
SSLEngine on
SSLCertificateFile /path/example.com.cer
SSLCertificateKeyFile /path/example.com.key
SSLCertificateChainFile /path/fullchain.cer
SSLCACertificateFile /path/ca.cer
RewriteCond %{HTTP:Connection} =Upgrade [NC]
RewriteCond %{HTTP:Upgrade} =WebSocket [NC]
RewriteRule /(.*) ws://localhost:8000/$1 [P,L]
ProxyPreserveHost On
ProxyPass / http://localhost:8000/
ProxyPassReverse / http://localhost:8000/
</VirtualHost>
```

最後に `httpd` を再起動します。

```shell:
sudo systemctl restart httpd
```

# Origin Rules 設定

- [Available Origin Rules settings · Cloudflare Rules docs](https://developers.cloudflare.com/rules/origin-rules/features/#dns-record)

まずは DNS レコードとして以下の2つを準備します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/c8626a87-fe9d-b526-751f-afcb688c7c15.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/632d087e-6704-ae85-43c0-5e1af542f680.png)

その上で、Origin Rules を以下のように設定します。

- Expression はこちらを使います。
    - `any(http.request.headers["upgrade"][*] == "websocket") and any(http.request.headers["connection"][*] == "Upgrade") and any(http.request.headers["sec-websocket-version"][*] == "13")`
    - 追加で `http.host` や `http.request.uri.path` など、[Fields reference · Cloudflare Ruleset Engine docs](https://developers.cloudflare.com/ruleset-engine/rules-language/fields/) にあるフィールドを組み合わせて柔軟に条件設定できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/1fdb41d5-2a78-0cbd-c584-df5d8287f32a.png)

# Request Trace API による検証
実際にトラフィックを流す前に、以下のようにテストパターンを用意すれば、
Request Trace API を使って、どのルールにマッチするかを簡単にテストできます。

- [Request Trace · Cloudflare API Documentation](https://developers.cloudflare.com/api/operations/account-request-tracer-request-trace)

```shell:
export EMAIL='YOUR_EMAIL'
export APIKEY='YOUR_APIKEY'
export ACCOUNT_ID='YOUR_ACCOUNT_ID'
```

```shell:
curl -s --request POST \
  --url https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/request-tracer/trace \
  -H 'Content-Type: application/json' \
  -H "X-Auth-Email: $EMAIL" \
  -H "X-Auth-Key: $APIKEY" \
  --data '{
  "headers": {
    "upgrade": "websocket",
    "connection": "Upgrade",
    "sec-websocket-version": "13"
  },
  "method": "GET",
  "protocol": "HTTP/1.1",
  "url": "https://wscat.example.com/"
}' | jq -r '.result.trace[]| select ( .step_name == "http_request_origin")'
```

設定した Origin Rules に対して `"matched": true` となっていることが確認できます。

```json:result.json
{
  "step_name": "http_request_origin",
  "type": "phase",
  "matched": true,
  "trace": [
    {
      "step_name": "e0bd256e76ea46eca49fd0ddb438a295",
      "type": "ruleset",
      "matched": true,
      "name": "My origin rules ruleset",
      "kind": "zone",
      "trace": [
        {
          "step_name": "8a34a3fe70764327a4820b817c585223",
          "type": "rule",
          "matched": true,
          "action_parameter": {
            "origin": {
              "host": "resolve.example.com"
            }
          },
          "expression": "any(http.request.headers[\"upgrade\"][*] == \"websocket\") and any(http.request.headers[\"connection\"][*] == \"Upgrade\") and any(http.request.headers[\"sec-websocket-version\"][*] == \"13\")",
          "description": "wscat-dns-override",
          "action": "route"
        }
      ]
    }
  ]
}
```

# `wscat` による疎通確認

- [WebSocketに対応したCloudFrontをwscatで疎通確認してみた | DevelopersIO](https://dev.classmethod.jp/articles/cloudfront-websocket-test-by-wscat/)

以下のコマンドでインストールし、

```shell:
sudo npm install -g wscat
```

WebSocket サーバ側でポート 8000 で待機します。

```shell:サーバ側
wscat -l 8000
```

クライアント側から以下のコマンドで疎通確認ができます。

```shell:クライアント側
wscat verbose -c wss://wscat.example.com:8443
```

## Orign Rules なし
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/11e59694-be84-8260-834b-a7b4d1f154cf.png)

この場合には、特に WebSocket サーバを用意していないので、

想定通りの 522 Connection timed out エラーになります。

```shell:
% wscat verbose -c wss://wscat.example.com:8443
error: Unexpected server response: 522
>               
```

## Origin Rules あり
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/a73fe8a9-db43-cb0e-a543-6204ba58b5a4.png)

以下のように疎通できたことが確認できます。

↓クライアント側
```shell:
Connected (press CTRL+C to quit)
> hello websocket
> 
```

↓サーバ側
```shell:
Listening on port 8000 (press CTRL+C to quit)
Client connected
< hello websocket
> 
```

# ログ確認

以下のようにカスタムログフィールドを設定して、WebSocket 通信に関するログをわかりやすくします。

- [Custom fields · Cloudflare Logs docs](https://developers.cloudflare.com/logs/reference/custom-fields/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/f09d2d9e-852d-5618-dc1d-53fe383bf77a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/85048416-9d6e-0de6-18fe-c03c3275933c.png)

## Origin Rules なし
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/9b1caaf4-6e1a-7c60-d836-2e102031d6d7.png)

`"OriginIP": "1.2.3.4"` には、特に WebSocket サーバを用意していないので、想定通りの `522: Origin Connection Time-out` エラーになります。
`"OriginResponseStatus": 0,` と `"EdgeResponseStatus": 522,` が確認できます。
クライアント側から接続を切った場合は、`499: Client Closed Request` となります。

```json:websocket_log.json
{
    ...
    "CacheCacheStatus": "unknown",
    ...
    "ClientRequestHost": "wscat.example.com:8443",
    "ClientRequestMethod": "GET",
    "ClientRequestPath": "/",
    "ClientRequestProtocol": "HTTP/1.1",
    "ClientRequestReferer": "",
    "ClientRequestScheme": "https",
    "ClientRequestSource": "eyeball",
    "ClientRequestURI": "/",
    "ClientRequestUserAgent": "",
    "ClientSSLCipher": "AEAD-AES256-GCM-SHA384",
    "ClientSSLProtocol": "TLSv1.3",
    "ClientSrcPort": 49511,
    "ClientTCPRTTMs": 9,
    "ClientXRequestedWith": "",
    "Cookies": {},
    ...
    "EdgeResponseStatus": 522,
    ...
    "OriginDNSResponseTimeMs": 0,
    "OriginIP": "1.2.3.4",
    "OriginRequestHeaderSendDurationMs": 0,
    "OriginResponseDurationMs": 0,
    "OriginResponseHTTPExpires": "",
    "OriginResponseHTTPLastModified": "",
    "OriginResponseHeaderReceiveDurationMs": 0,
    "OriginResponseStatus": 0,
    "OriginSSLProtocol": "none",
    "OriginTCPHandshakeDurationMs": 0,
    "OriginTLSHandshakeDurationMs": 0,
    "ParentRayID": "00",
    "RayID": "7d51972ffa4480f0",
    "RequestHeaders": {
        "upgrade": "websocket",
        "connection": "Upgrade",
        "sec-websocket-version": "13",
        "sec-websocket-key": "PdExxoiLWMfeq49HXEzcAw=="
    },
    "ResponseHeaders": {},
    ...
}
```

## Origin Rules あり
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/3726c11d-8209-5e12-d77e-bb667e008656.png)

具体的には、以下のようなログが確認できます。

`"OriginIP": "x.x.x.x"` となっているため、正しく DNS レコードの上書きアクションが適用され、`"OriginResponseStatus": 101,` と `"EdgeResponseStatus": 101,` から WebSocket 通信が確立（`101: Switching Protocols`）され、`"RequestHeaders"` にも WebSocket に関わるヘッダがあることがわかります。

```json:websocket_log.json
{
    ...
    "CacheCacheStatus": "dynamic",
    ...
    "ClientRequestHost": "wscat.example.com:8443",
    "ClientRequestMethod": "GET",
    "ClientRequestPath": "/",
    "ClientRequestProtocol": "HTTP/1.1",
    "ClientRequestReferer": "",
    "ClientRequestScheme": "https",
    "ClientRequestSource": "eyeball",
    "ClientRequestURI": "/",
    "ClientRequestUserAgent": "",
    "ClientSSLCipher": "AEAD-AES256-GCM-SHA384",
    "ClientSSLProtocol": "TLSv1.3",
    "ClientSrcPort": 56790,
    "ClientTCPRTTMs": 5,
    "ClientXRequestedWith": "",
    "Cookies": {},
    ...
    "EdgeResponseStatus": 101,
    ...
    "OriginDNSResponseTimeMs": 1,
    "OriginIP": "x.x.x.x",
    "OriginRequestHeaderSendDurationMs": 0,
    "OriginResponseDurationMs": 20,
    "OriginResponseHTTPExpires": "",
    "OriginResponseHTTPLastModified": "",
    "OriginResponseHeaderReceiveDurationMs": 10,
    "OriginResponseStatus": 101,
    "OriginSSLProtocol": "TLSv1.3",
    "OriginTCPHandshakeDurationMs": 3,
    "OriginTLSHandshakeDurationMs": 7,
    "ParentRayID": "00",
    "RayID": "7d4b23229a9b1f4f",
    "RequestHeaders": {
        "upgrade": "websocket",
        "connection": "Upgrade",
        "sec-websocket-version": "13",
        "sec-websocket-key": "xnjFdJ9fsAy/TKNdusLTyg=="
    },
    "ResponseHeaders": {},
    ...
}
```


# Cloudflare with WebSockets
その他、Cloudflare を利用する際の WebSocket 通信に関する留意点はこちらをみてください。

- [Using Cloudflare with WebSockets · Cloudflare Support docs](https://developers.cloudflare.com/support/network/using-cloudflare-with-websockets/)