AWS WAFを使用してメンテナンスモードの切り替えをスクリプトで行う

メンテナンスの際に、手軽にAPIやフロントのメンテナンスモードを切り替える仕組みが欲しかったので作りました。

具体的には、IP制限やメンテナンス用のレスポンス定義を、スクリプトで切り替えられるようになります。


やりたいこと

  • APIやフロントのメンテナンスモードの切り替えをスクリプトで手軽に行えるようにしたい
    • メンテナンスモードON
      • 特定のIPリストからのみアクセスを許可する
      • IPリスト以外からアクセスがあったときは、メンテナンス中を示す特定のステータスコード・ヘッダー・ボディをレスポンスで返す(詳細は後述)
    • メンテナンスモードOFF
      • 通常時
      • 全てのアクセスを許可する(IP制限なし


方法の概要

以下の記事の内容を合わせて(IP制限+WAFカスタムレスポンスによるメンテナンスモード)、さらにそれをスクリプトでオンオフ切り替えられるようにします。


IP制限

go-to-k.hatenablog.com


APIのメンテナンスモード

go-to-k.hatenablog.com


フロント環境のメンテナンスモード(ページ)

go-to-k.hatenablog.com


どれもWAFを使用しているのですが、今回はREST API、GraphQL API、CloudFrontどれにでも使い回せる設定で作ってみます。

具体的には、メンテナンスモード時に許可IP以外のアクセスに返すレスポンスヘッダー・ボディやhttpステータスコードを、REST API、GraphQL API、CloudFrontでそれぞれ違うものを返すようにしてみました。 

  • メンテナンスモードON
  • メンテナンスモードOFF
    • 何も変えない

※注意として、WAFはメンテナンスモードがオフのときも常にリソースにつけっぱなしになります。

どうしても料金が気になる場合は使えなくなってしまいますが、例えばAWSが提供するマネージドルールをこのWAFのルールにつけて常にアタッチしておくようにすれば、セキュリティ対策のついでに本切り替え手法も使える様になるのでおすすめです。


登場するモノ

  • WAF
  • CloudFormation
  • シェルスクリプト(bash)
  • ※今回はWAFのみに焦点を当てるため、アタッチ先のリソース(CloudFrontなど)は載せていません。


前提

  • 今回の方法が使えるのはAWS WAFがアタッチできるサービスに限られるため、対象はAPI Gateway, Appsync, ELB, CloudFrontなどに限ります。


方法

CloudFormationテンプレートファイル(yml)・IPホワイトリスト用テキストファイル・シェルスクリプト(bash)を用意します。

GitHubにもあります。

github.com


CloudFormationテンプレートファイル(yml)

  • ファイル名:waf.yml
### WAF v2 ###

AWSTemplateFormatVersion: "2010-09-09"

Description: WAF v2 for Maintenance

Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "MaintenanceMode"
        Parameters:
          - MaintenanceMode
      - Label:
          default: "ScopeType"
        Parameters:
          - ScopeType
      - Label:
          default: "WAFWebACLMetricName"
        Parameters:
          - WAFWebACLMetricName
      - Label:
          default: "CIDR1"
        Parameters:
          - CIDR1
      - Label:
          default: "CIDR2"
        Parameters:
          - CIDR2
      - Label:
          default: "CIDR3"
        Parameters:
          - CIDR3
      - Label:
          default: "CIDR4"
        Parameters:
          - CIDR4
      - Label:
          default: "CIDR5"
        Parameters:
          - CIDR5

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  MaintenanceMode:
    Type: String
    AllowedValues: ["on", "off"]

  ScopeType:
    Type: String
    AllowedValues: ["CLOUDFRONT", "GRAPHQL", "REST"]

  WAFWebACLMetricName:
    Type: String
    MinLength: 1
    MaxLength: 128
    AllowedPattern: "[a-zA-Z0-9]*"

  CIDR1:
    Type: String
    Description: White List CIDR 1
    Default: ""
    AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$'
  CIDR2:
    Type: String
    Description: White List CIDR 2
    Default: ""
    AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$'
  CIDR3:
    Type: String
    Description: White List CIDR 3
    Default: ""
    AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$'
  CIDR4:
    Type: String
    Description: White List CIDR 4
    Default: ""
    AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$'
  CIDR5:
    Type: String
    Description: White List CIDR 5
    Default: ""
    AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$'

# ------------------------------------------------------------#
#  Conditions
# ------------------------------------------------------------#
Conditions:
  IsMaintenance: !Equals [!Ref MaintenanceMode, "on"]
  IsCloudFront: !Equals [!Ref ScopeType, "CLOUDFRONT"]
  IsGraphql: !Equals [!Ref ScopeType, "GRAPHQL"]
  IsCIDR1: !Or
    - !Not [!Equals [!Ref CIDR1, ""]]
    - !Equals [!Ref CIDR1, ""]
  IsCIDR2: !Not [!Equals [!Ref CIDR2, ""]]
  IsCIDR3: !Not [!Equals [!Ref CIDR3, ""]]
  IsCIDR4: !Not [!Equals [!Ref CIDR4, ""]]
  IsCIDR5: !Not [!Equals [!Ref CIDR5, ""]]

Resources:
  # ------------------------------------------------------------#
  # WAF
  # ------------------------------------------------------------#
  WhiteListCIDRSet:
    Type: AWS::WAFv2::IPSet
    Properties:
      Addresses:
        - !If
          - IsCIDR1
          - !Ref CIDR1
          - !Ref "AWS::NoValue"
        - !If
          - IsCIDR2
          - !Ref CIDR2
          - !Ref "AWS::NoValue"
        - !If
          - IsCIDR3
          - !Ref CIDR3
          - !Ref "AWS::NoValue"
        - !If
          - IsCIDR4
          - !Ref CIDR4
          - !Ref "AWS::NoValue"
        - !If
          - IsCIDR5
          - !Ref CIDR5
          - !Ref "AWS::NoValue"
      IPAddressVersion: IPV4
      Name: Maintenance-WebACL-IPSet
      Scope: !If
        - IsCloudFront
        - "CLOUDFRONT"
        - "REGIONAL"

  WAFWebACL:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: Maintenance-WebACL
      CustomResponseBodies: !If
        - IsMaintenance
        - !If
          - IsGraphql
          - CustomResponseBodyKeyForGraphql:
              ContentType: APPLICATION_JSON
              Content: '{"errors": [{"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}]}'
          - !If 
            - IsCloudFront
            - !Ref "AWS::NoValue"
            - CustomResponseBodyKeyForRest:
                ContentType: APPLICATION_JSON
                Content: '{"error": {"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}}'
        - !Ref "AWS::NoValue"
      DefaultAction:
        Block: !If
          - IsMaintenance
          - !If
            - IsGraphql
            - CustomResponse:
                ResponseCode: 200
                CustomResponseBodyKey: CustomResponseBodyKeyForGraphql
            - !If
              - IsCloudFront
              - CustomResponse:
                  ResponseCode: 403
                  ResponseHeaders:
                    - Name: CustomErrorType
                      Value: "MaintenanceMode"
              - CustomResponse:
                  ResponseCode: 503
                  CustomResponseBodyKey: CustomResponseBodyKeyForRest
                  ResponseHeaders:
                    - Name: CustomErrorType
                      Value: "MaintenanceMode"
          - !Ref "AWS::NoValue"
        Allow: !If
          - IsMaintenance
          - !Ref "AWS::NoValue"
          - {}
      Scope: !If
        - IsCloudFront
        - "CLOUDFRONT"
        - "REGIONAL"
      VisibilityConfig:
        CloudWatchMetricsEnabled: true
        MetricName: !Ref WAFWebACLMetricName
        SampledRequestsEnabled: true
      Rules:
        - !If
          - IsMaintenance
          - Name: !Sub "${PJPrefix}-Maintenance-WebACL-RuleIPSet"
            Action:
              Allow: {}
            Priority: 0
            Statement:
              IPSetReferenceStatement:
                Arn: !GetAtt WhiteListCIDRSet.Arn
            VisibilityConfig:
              CloudWatchMetricsEnabled: true
              MetricName: !Sub "${WAFWebACLMetricName}-RuleIPSet"
              SampledRequestsEnabled: true
          - !Ref "AWS::NoValue"


解説

※ここで解説しないものに関しては、上記でご紹介したリンク先の方で解説していますのでご覧下さい。


ScopeTypeには"CLOUDFRONT", "REGIONAL"ではなく、"CLOUDFRONT", "GRAPHQL", "REST"それぞれを渡すようにしました。

  ScopeType:
    Type: String
    AllowedValues: ["CLOUDFRONT", "GRAPHQL", "REST"]


Conditionsには、3種類のフラグを追加しました。

Conditions:
  IsMaintenance: !Equals [!Ref MaintenanceMode, "on"]
  IsCloudFront: !Equals [!Ref ScopeType, "CLOUDFRONT"]
  IsGraphql: !Equals [!Ref ScopeType, "GRAPHQL"]


WhiteListCIDRSetには、上記リンクの手法(CIDR1~5IsCIDR1~5パラメータ)を使って、動的にIPを設定できるようにしています。


WhiteListCIDRSetとWAFWebACLのどちらにも出てくるScopeでは、ScopeTypeパラメータに["CLOUDFRONT", "GRAPHQL", "REST"]が入っているので、CloudFrontかどうかをConditionsで定義したIsCloudFrontIf関数によって分岐させています。

  • ScopeType:CLOUDFRONT -> Scope:CLOUDFRONT
  • ScopeType:GRAPHQL, REST -> Scope:REGIONAL
      Scope: !If
        - IsCloudFront
        - "CLOUDFRONT"
        - "REGIONAL"


ここからメインのWAFWebACLです。

CustomResponseBodiesからです。

      CustomResponseBodies: !If
        - IsMaintenance
        - !If
          - IsGraphql
          - CustomResponseBodyKeyForGraphql:
              ContentType: APPLICATION_JSON
              Content: '{"errors": [{"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}]}'
          - !If 
            - IsCloudFront
            - !Ref "AWS::NoValue"
            - CustomResponseBodyKeyForRest:
                ContentType: APPLICATION_JSON
                Content: '{"error": {"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}}'
        - !Ref "AWS::NoValue"

まず、メンテナンスモードかどうかを表すIsMaintenanceIfでまず分岐をします。

メンテナンスモードがオンのときはさらにネストした分岐へ、オフのときは!Ref "AWS::NoValue"CustomResponseBodies自体の定義を無くしています。

これにより、メンテナンスモードのときのみCustomResponseBodiesを定義することが可能になります。

またメンテナンスモードがオンのときは、今度はIsGraphqlIsCloudFrontのフラグを使って分岐し、それぞれのボディを定義しています。

  • GraphQLのとき
    • CustomResponseBodyKeyForGraphql(errorsキーの中に配列を持つ)
  • CloudFrontのとき
    • !Ref "AWS::NoValue"(ボディ定義なし)
  • それ以外(RESTのとき)
    • CustomResponseBodyKeyForRest(errorオブジェクト)


次はDefaultActionです。

      DefaultAction:
        Block: !If
          - IsMaintenance
          - !If
            - IsGraphql
            - CustomResponse:
                ResponseCode: 200
                CustomResponseBodyKey: CustomResponseBodyKeyForGraphql
            - !If
              - IsCloudFront
              - CustomResponse:
                  ResponseCode: 403
                  ResponseHeaders:
                    - Name: CustomErrorType
                      Value: "MaintenanceMode"
              - CustomResponse:
                  ResponseCode: 503
                  CustomResponseBodyKey: CustomResponseBodyKeyForRest
                  ResponseHeaders:
                    - Name: CustomErrorType
                      Value: "MaintenanceMode"
          - !Ref "AWS::NoValue"
        Allow: !If
          - IsMaintenance
          - !Ref "AWS::NoValue"
          - {}

ここでは、BlockもAllowも定義しています。

これはIsMaintenanceによって、メンテナンスモードオンのときはBlockが定義され、オフのときはAllowが定義される仕組みです。

  • IsMaintenance -> on(true)
    • Block
      • あり(カスタムレスポンス)
    • Allow
      • なし(!Ref "AWS::NoValue")
  • IsMaintenance -> off(false)
    • Block
      • なし(!Ref "AWS::NoValue")
    • Allow
      • あり({})

また、各ステータスコード・ヘッダー・ボディも、本記事前半で定義した様になっています。


最後はRulesですが、こちらもIsMaintenanceによって、オンのときは定義され、オフのときは未定義となります。

      Rules:
        - !If
          - IsMaintenance
          - Name: !Sub "${PJPrefix}-Maintenance-WebACL-RuleIPSet"
            Action:
              Allow: {}
            Priority: 0
            Statement:
              IPSetReferenceStatement:
                Arn: !GetAtt WhiteListCIDRSet.Arn
            VisibilityConfig:
              CloudWatchMetricsEnabled: true
              MetricName: !Sub "${WAFWebACLMetricName}-RuleIPSet"
              SampledRequestsEnabled: true
          - !Ref "AWS::NoValue"


IPホワイトリスト用テキストファイル(txt)

  • ファイル名:iplist_waf.txt
123.456.789.10/32
234.567.890.12/32

これはちゃんとCIDR表記(/16とか/32とか)にしましょう。(WAFのIPセットではこの形でないといけない)

IPを範囲ではなく1つ1つ定義する場合は、「/32」をつけておけば大丈夫です。

先頭にコメントアウトをつけた行は、後述のシェルスクリプトの処理によって飛ばされ、行の途中に入れたコメントアウトも飛ばされます。


シェルスクリプト(bash)

  • ファイル名:maintenance.sh
#!/bin/bash
set -eu

iplist_file="./iplist_waf.txt"
iplist_max=5

CFN_TEMPLATE="./waf.yml"
CFN_REGION="us-east-1"
CFN_STACK_NAME="WafIp"

CFN_WAFWebACLMetricName="WafIpWebacl"
CFN_ScopeType="CLOUDFRONT"

MaintenanceMode=""

while getopts m: OPT; do
    case $OPT in
        m)
            MaintenanceMode="$OPTARG"
            ;;
    esac
done


if [ -z ${MaintenanceMode} ] \
|| [ "${MaintenanceMode}" != "on" -a "${MaintenanceMode}" != "off" ]; then
  echo "-m(maintenance mode): on/off" 
  exit 1
fi


cnt=0

for ip in `cat ${iplist_file} | tr -d " \t" | grep -v "^#" | sed -e "s/\([^#]*\)#.*$/\1/g"`
do
  if [ -n "${ip}" ]; then
    let cnt++
    eval CFN_CIDR${cnt}="${ip}"
  fi

  if [ $cnt -ge $iplist_max ]; then
    echo "${iplist_max}個の制限を超えたのでここまでのIPを登録します。"
    echo "最後のIP: ${ip}"
    break
  fi
done

opt_param=""

for i in `seq 1 $iplist_max`
do
  if [ $i -gt $cnt ]; then
    eval CFN_CIDR${i}=""
  fi
  
  opt_param="${opt_param} CIDR${i}=$(eval echo '$'CFN_CIDR${i})"  
done


aws cloudformation deploy \
    --template-file ${CFN_TEMPLATE} \
    --region ${CFN_REGION} \
    --stack-name ${CFN_STACK_NAME} \
    --no-fail-on-empty-changeset \
    --parameter-overrides \
    WAFWebACLMetricName=${CFN_WAFWebACLMetricName} \
    ScopeType=${CFN_ScopeType} \
    MaintenanceMode=${MaintenanceMode} \
    ${opt_param}


解説

※IPリストファイルの読み込み、CIDR変数の動的定義と展開などは上記でご紹介したリンク先の方で解説していますのでご覧下さい。


今回はCloudFrontにアタッチするスクリプトを例として挙げています。

ScopeTypeは["CLOUDFRONT", "GRAPHQL", "REST"]という形をyml側で許可しているので、GraphQL用・REST用のWAFを作成する場合は変更してください。

シェルを変えるだけでymlは同じファイルを使って、それぞれのWAFを作成することができます。

CFN_ScopeType="CLOUDFRONT"


MaintenanceModeという変数を、このシェルの引数-mもらうようにしました。

またCloudFormation CLIのデプロイコマンドのオプションにも渡しています。

while getopts m: OPT; do
    case $OPT in
        m)
            MaintenanceMode="$OPTARG"
            ;;
    esac
done


MaintenanceModeが指定されないか、[on|off]以外のときは終了させています。

if [ -z ${MaintenanceMode} ] \
|| [ "${MaintenanceMode}" != "on" -a "${MaintenanceMode}" != "off" ]; then
  echo "-m(maintenance mode): on/off" 
  exit 1
fi


デプロイ

上記ファイルをすべて同階層において シェルスクリプトを実行すればデプロイが走ります。

またシェル実行に関して、MaintenanceModeというオプションを-mで渡すようにしたので、メンテナンスモードがオン・オフでそれぞれ以下のような実行になります。

メンテナンスモードオン

sh maintenance.sh -m on

メンテナンスモードオフ

sh maintenance.sh -m off


これらをそれぞれメンテナンス開始時・終了時に叩くことで、メンテナンスモードの切り替えを手軽に行える様になります。

また、一度オンにしてメンテナンスモードにし、諸々のメンテナンス作業を終えた後にオフにするのも忘れがちなので気をつけましょう。(うまくパイプラインに組み込めると楽です


IPリスト変更時

IPを追加したり削除したりする場合は、IPリストを変更し、再度シェルスクリプトを実行してください。


まとめ

AWS WAFのカスタムレスポンスやIP制限などの機能を使って、手軽にメンテナンスモードの切り替えができるようなスクリプトを作ってみました。

私はこれをさらに拡張して、複数アプリ・マイクロサービス(フロントもREST APIもGraphQL APIも)のメンテナンスモードの切り替えを一元的に・選択式で同時実行を行うツールを作って使用しています。

またこのWAFにマネージドルールも追加したりと、手軽にアレンジできて色々応用も効くので、ぜひこの記事が参考になれば幸いです。