[Cloud][CDN][Azure][Linux] ブログサイトアクセス制御

こういうことがしたい

と言う図を貼ります。

やりたいこと概要

要は、外部からサイトを閲覧するユーザには、必ずCDN越しに来てもらいたいということです。それを強制するためのアクセス制御について考えました。

Nginxにおけるアクセス制御

我が家のWebインフラは、前段にNginxを立てています。これがリバースプロキシとなって、後ろに配置されたKubernetes上のコンテナ群が実体となり、アクセスを受け付ける仕様になっています。

つまりは、Nginx側でACLを仕掛ければある程度不正アクセスをガードすることは可能だったりします。書式は簡単なもんで、例えば当ブログに関しては、外部からのアクセスに対して管理画面なんかを基本denyする設定を入れてますが、

  location /wp-admin/ {
    allow 10.0.0.0/16;
    allow 10.5.0.0/16;
    allow 192.168.0.0/16;
    deny all;

    proxy_set_header Host $host; (以下略)

こんな感じ。ルールは上段から順に処理されるようで、最後にdeny allを入れることで、それ以外のアクセスは許容しないというふうに設定できるようです。

Azure CDNではどうか?

外部監視とかしてると知ってる人も多いと思うのですが、たいていこういうサービスってアクセス元グローバルアドレスを公開なんかしてたりして、それをNginx側のACLに登録すれば一発じゃないの?と最初私は思ってました。

が、現実はどうもそうは甘くなかったようです。

Azure CDN の現在の Verizon POP リストの取得 |Microsoft Docs https://docs.microsoft.com/ja-jp/azure/cdn/cdn-pop-list-api

え、情報これだけ?と言う感じだったのですが、その後段の文章で若干凍りつくことになります。

セキュリティ上の目的で、この IP リストを使用して、配信元サーバーへの要求が有効な Verizon POP からのみ行われるようにすることができます。 
たとえば、CDN エンドポイントの配信元サーバーのホスト名または IP アドレスを発見しただれかが、Azure CDN によって提供されるスケーリングおよびセキュリティ機能をバイパスして配信元サーバーに直接要求を行う可能性があります。
返されるリスト内の IP アドレスを配信元サーバー上の唯一の許可される IP アドレスとして設定することで、このシナリオを回避できます。
確実に最新の POP リストを使用するには、POP リストを少なくとも 1 日に 1 回取得します。

確実に最新の POP リストを使用するには、POP リストを少なくとも 1 日に 1 回取得します。」??

おおう、クラウドあるあるキタヨ・・・と言う感じでした。

Verizon POPリストの取得

Verizon POPリストの取得に関しては以下のサイトに情報があります。

Edge Nodes – List (Azure CDN) | Microsoft Docs
https://docs.microsoft.com/ja-jp/rest/api/cdn/edgenodes/list

どうやらREST APIを使用してリストを取得する必要がありそうです。そして、こいつは厄介なことに

  • ちゃんとトークン認証を受けないとリストが得られない
  • 応答はJSON形式

ということがわかりました。仕方がない、ちゃんと作り込もう・・・Orz

トークン認証に必要なものを作成

参考にしたのは以下のウェブサイトでした。

流れとしては

  • Azure AD上にアプリケーション用アカウントを作成する
  • アカウントに対応する公開鍵を作成する
  • アカウントID/公開鍵を控えておく
  • 別途Azure AD上のプロパティからテナントIDを控えておく
  • アカウントID/公開鍵/テナントIDを使用して、curlコマンドでトークンを引き出す

という感じになる。curlに載せる内容を転載しておきます。

curl -X POST \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=client_credentials" \
 -d "resource=https://management.azure.com/" \
 -d  "client_id=${CLIENTID}" \
 --data-urlencode  "client_secret=${CLIENTSECRET}" https://login.windows.net/${TENANTID}/oauth2/token?api-version=1.0

出力結果はJSON形式で、以下のような感じになります。

{
 "token_type":"Bearer",
 "expires_in":"3600",
 "ext_expires_in":"0",
 "expires_on":"1538958968",
 "not_before":"1538955068",
 "resource":"https://management.azure.com/",
 "access_token":"<トークン文字列>"
}

上記「expires_in」を見る限り、有効期間は1時間程度に見えますね。ということは、リスト更新のたびにこのコマンドは叩いたほうが良さそうだなと。

Verizon POP Listの取得

先述したMicorosoftの情報から、どうやら以下の通り呼び出せば良さそう。

curl -X GET \
 -H "Authorization: Bearer ${KEY}" \
 -H "Content-Type:application/json" \
 https://management.azure.com/providers/Microsoft.Cdn/edgenodes?api-version=2017-10-12

${KEY}としている箇所は、先に取得したトークン本体を埋め込む形になるようです。こちらも応答形式はJSONになってます。

{
  "value":[
    {
      "name":"Standard_Verizon","id":"/providers/Microsoft.Cdn/edgenodes/Standard_Verizon","type":"Microsoft.Cdn/edgenodes","properties":{
        "ipAddressGroups":[
          {
            "deliveryRegion":"All","ipv4Addresses":[
              {
                "baseIpAddress":"5.104.64.0","prefixLength":21
              },{
                "baseIpAddress":"46.22.64.0","prefixLength":20
              },{
				:
				:
              }
            ],"ipv6Addresses":[
              {
                "baseIpAddress":"2001:2011:c002::","prefixLength":48
				:
				:
              }
            ]
          }
        ]
      }
    },{
      "name":"Premium_Verizon","id":"/providers/Microsoft.Cdn/edgenodes/Premium_Verizon","type":"Microsoft.Cdn/edgenodes","properties":{
        "ipAddressGroups":[
          {
            "deliveryRegion":"All","ipv4Addresses":[
              {
                "baseIpAddress":"5.104.64.0","prefixLength":21
              },{
				:
				:
              }
            ],"ipv6Addresses":[
              {
                "baseIpAddress":"2001:2011:c002::","prefixLength":48
              },{
				:
				:
              }
            ]
          }
        ]
      }
    },{
      "name":"Custom_Verizon","id":"/providers/Microsoft.Cdn/edgenodes/Custom_Verizon","type":"Microsoft.Cdn/edgenodes","properties":{
        "ipAddressGroups":[
          {
            "deliveryRegion":"All","ipv4Addresses":[
              {
                "baseIpAddress":"5.104.64.0","prefixLength":21
				:
				:
              }
            ],"ipv6Addresses":[
              {
                "baseIpAddress":"2001:2011:c002::","prefixLength":48
              },{
				:
				:
              }
            ]
          }
        ]
      }
    }
  ]
}

これでVerizon版すべてのCDNのPOPリストが載ってるようで、Standard/Premium/Custom全部が載っています。今回このリストのカテゴリは気にせず、IPv4アドレスをすべて抽出することを考えました。(本当は重複を避けてPremiumだけ取得するようにしたほうがいいのだろうけど、そこまで気が回せんかった)

jqを使用したデータ抽出

今回、なにか言語使って処理しようかとも考えたのだけど、この対応を取る状況が結構急ぎだったので、安直にシェルスクリプトで対応することに。シェルスクリプトでJSONを処理するには、jqというものがあるらしく、コレがいいらしい。今回はこんなふうに使っている。

cat ${JSONDATA} |jq -r '.value[].properties.ipAddressGroups[].ipv4Addresses[]| [.baseIpAddress, .prefixLength] |@csv'

やってることとしては、value->properties->ipAddressGroups->ipv4Addresses配下にある「baseIpAddress」及び「prefixLength」の「値」をCSV形式で取得するというものになってます。

要素名の前に「.(ドット)」を付与することで、値を採取することができるようです。末尾の|@csvという記述によって、出力形式がCSVになるようで、実際の出力としては以下のようになってました。

"5.104.64.0",21
"46.22.64.0",20
				:
				:
				:
"88.194.47.224",27

nginx ACL形式に変換

コレはかなり都合が良くて、sedで文字を変換したら簡単にnginx形式に変換できるなぁということに気づく。以下のように文字変換処理を加えて、nginx形式に持っていくことが出来ました。

■サブネットマスクの間の「”,」を「/」に変換し、先頭の「”」を「allow 」に変換
sed -e 's/\"\,/\//g' ${TRANS_TEMP} |sed -e 's/\"/allow  /g'

■末尾に「;」を付与
sed -e 's/$/;/g' ${TRANS_TEMP2} 

2つに処理が別れたのは、どうしてもうまく1行では処理できなかったから。無念・・・

何はともあれ、これで、出力リストが以下のようになる。

allow  5.104.64.0/21;
allow  46.22.64.0/20;
				:
				:
				:
allow  88.194.47.224/27;

Nginx設定へ埋め込み

後は生成されたファイルをincludeしてあげればよいわけで、例えばコンフィグファイルの/(スラッシュ)に対する設定の中で以下のように書けばよいということになる。

  location / {
    include /etc/nginx/whitelist/azurecdn-pop-whitelist.conf;
    allow  10.0.0.0/16;
    allow  10.5.0.0/16;
    allow  192.168.0.0/16;
    deny all;

最終的に定期実行シェルとしてまとめる。

処理の流れは大体つかめたので、この内容をもとに、シェルスクリプトを作成する。

#!/bin/bash

#Valiables
CLIENTID="<アプリケーションアカウントID>"
CLIENTSECRET="<アプリケーションアカウントの公開鍵>"
TENANTID="<テナントID/ディレクトリIDとも言うかも>"
RESULT=/etc/nginx/whitelist/azurecdn-pop-whitelist.conf
LOGFILE=/var/log/azurecdn-listupdate.log

#Temp files
KEYFILE=/etc/nginx/whitelist/tmp/authorized_key_azurecdn.raw
JSONDATA=/etc/nginx/whitelist/tmp/azurecdn-pop-verizon.list
TRANS_TEMP=/etc/nginx/whitelist/tmp/azurecdn-pop-current.csv
TRANS_TEMP2=/etc/nginx/whitelist/tmp/azurecdn-tmpfile.list

#Messages
MESSAGE1="[INFO] Start AzureCDN POP list update sequence."
MESSAGE2="[INFO] Complete login to microsoft azure."
MESSAGE3="[INFO] Complete get list from Azure CDN."
MESSAGE4="[INFO] Complete converted JSON data to Nginx config."
MESSAGE5="[INFO] Complete restart nginx service."
ERR1="[ERR] Failed converted JSON data to Nginx config."

#Start
echo `date` ${MESSAGE1} >> ${LOGFILE}

#Get Authorized_Key
curl -X POST \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=client_credentials" \
 -d "resource=https://management.azure.com/" \
 -d  "client_id=${CLIENTID}" \
 --data-urlencode  "client_secret=${CLIENTSECRET}" https://login.windows.net/${TENANTID}/oauth2/token?api-version=1.0 > ${KEYFILE}

echo `date` ${MESSAGE2} >> ${LOGFILE}

#Pickup key-token from KEYFILE
KEY=`cat ${KEYFILE}|jq -r '.access_token'`

#Get POP server list from AzureCDN
curl -X GET \
 -H "Authorization: Bearer ${KEY}" \
 -H "Content-Type:application/json" \
 https://management.azure.com/providers/Microsoft.Cdn/edgenodes?api-version=2017-10-12 \
 > ${JSONDATA}

echo `date` ${MESSAGE3} >> ${LOGFILE}

#Get IPv4 address list from POP server list
cat ${JSONDATA} |jq -r '.value[].properties.ipAddressGroups[].ipv4Addresses[]| [.baseIpAddress, .prefixLength] |@csv' > ${TRANS_TEMP}

#Translation JSON to Nginx config
sed -e 's/\"\,/\//g' ${TRANS_TEMP} |sed -e 's/\"/allow  /g' > ${TRANS_TEMP2}
sed -e 's/$/;/g' ${TRANS_TEMP2} > ${RESULT}

echo `date` ${MESSAGE4} >> ${LOGFILE}

#Restart Nginx
if [ -s ${RESULT} ]; then
 systemctl restart nginx
 echo `date` ${MESSAGE5} >> ${LOGFILE}
else
 echo `date` ${ERR1} >> ${LOGFILE}
fi

うーん、なんとも恥ずかしいコードと言うか、一々テキストファイルを生成して処理しているところが恥ずかしさの極みなんだけど、ご容赦くださいな。
とりあえずは、途中で処理失敗した場合、リストの中は空になることが分かっているので、その時はnginxを再起動しないように最低限のエラーハンドリングだけしている感じです。

CDNにもよりますが

ぶっちゃけCloudFrontやCloudFlareについてはこのあたりよく分かっていないのですが、少なくともAkamai本家に関しては、こうした所は作り込みをせずとも、ある程度POPを絞って定義できる、SiteShieldというものが存在するようです。

どーしてもCDNは不特定多数のPOPでやりくりする側面があり、かつ、大量のホストで処理してなんぼな世界でもあったりするが故、こうした面倒なポイントというのは発生してしまうようです。

今回、収穫としては、JSONについて少し中身を触ることが出来たこと、Azureに対するキートークン認証に触れることが出来たことの2つかな。最近こういう処理については少し逃げ腰だったところがあったので、良い勉強になりました。