メンテナンスページをWAFで作成するCDKとその切り替えをbashで実現する

# はじめに
メンテナンスページのWAFで作成し、その切り替えをbashで実現しました。
※切り替えは手動のため、CodeBuild化をその後行いました。後ほど記事にしようと思います。

また、[参考](#参考)にも記載しておりますが、実装するにあたって以下の記事を参考にさせていただきました。

https://zenn.dev/not75743/articles/c65556b642a599

https://zenn.dev/htnk128/articles/97e4821554b1ed#%E5%AE%9F%E8%A3%85%E5%86%85%E5%AE%B9

# 目次
- [実現したこと](#実現したこと)
  - [通常](#通常)
  - [メンテナンス時](#メンテナンス時)
  - [実装方針](#実装方針)
- [ディレクトリ構成](#ディレクトリ構成)
- [実装したCDK](#実装したcdk)
  - [処理の流れ](#処理の流れ)
  - [コード](#コード)
- [実装したbash](#実装したbash)
  - [処理の流れ](#処理の流れ)
    - [start](#start)
    - [end](#end)
  - [コード](#コード)
    - [start](#start)
    - [end](#end)
    - [jsons](#jsons)
- [参考](#参考)

# 実現したこと
今回のメンテナンスページは、WAFのルールグループとカスタムレスポンスを使って制御しました。

## 通常
![通常](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3516746/278a78a0-3f91-7316-d48b-348921266a17.png)

CloudFrontとLambdaで構成しているアーキテクチャに、WAFで通常ルールグループとメンテナンスルールグループの2つを作成し、WebACLにアタッチしています。また、デフォルトルールグループの中に、セキュリティ対策のため5分間に1000件のリクエストがきたらブロックするルールを設け、メンテナンスルールグループの方はルールを空にしています。
そのため、5分間に1000件のリクエストを送るユーザー以外はアクセス可能です。

## メンテナンス時
![メンテナンス時](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3516746/3b68424a-1048-3d1c-a38d-3455db5d32cb.png)

メンテナンス中は、ルールグループは通常時と変わりませんが、メンテナンスルールグループの中にメンテナンス担当のIPアドレスのみ許可するルールを作成し、アクセスできる人を限定的にします。
そのため、メンテナンス担当のIPアドレスからのみアクセス可能となり、その他のユーザーはWAFで503が返されてアクセスできないようになります。

## 実装方針
実装方針としては、今後デフォルトのルールが変わるかもしれないことを考慮し、CDKでルールグループをそれぞれ作り、デフォルトのルールグループには予めBlockHighRateを入れておき、メンテナンスの方は空として作成しました。また、メンテナンスのルールグループに入れるルール(AllowIpAddress)はJSON(maintenance_mode_rule.json)で管理する形にしました。

それぞれで行っていることに関しては以下の通りです。

- CDK
  - IPSet(中身は空)作成
  - WAFのRuleGroup(default/maintenance)作成
  - WebACL作成
- bash
  - start
    - パラメータで渡したIPが、IPSetにすでにあるかの確認
      - あれば処理終了
    - IP Setにパラメータで渡したIPアドレスを追加
    - maintenanceGroupにIPSetのルールを追加し、update-rule-groupを実行
    - WebACLにカスタムレスポンスを追加
  - end
    - メンテナンスグループの中身を空にする
    - WebACLデータ取得して、カスタムレスポンスを削除する
    - IPSetからIPアドレスを全て削除
  - JSON(custom_response_bodies)
    - メンテナンス時に返すレスポンスの中身
  - JSON(maintenance_mode_rule)
    - メンテナンス時、メンテナンスルールグループに入れるルールの中身(AllowIpAddress)

# ディレクトリ構成
ディレクトリの構成は以下の通りです。
```
┣ maintenance
  ┣ jsons
    ┣ custom_response_bodies.json
    ┗ maintenance_mode_rule.json
  ┣ start_maintenance.bash
  ┗ end_maintenance.bash
┗ ap-northeast-1-stack.ts
```
# 実装したCDK
## 処理の流れ
CDKの処理の詳細については以下の通りです。

### ap-northeast-1-stack.ts
- IPSet作成
  - 空のIPSetを作成
- Group(default/maintenance)
  - それぞれのARNをSSMで保存
- WebACL
  - ARNをSSMで保存

## コード
```typescript:'ap-northeast-1-stack.ts'
import * as cdk from "aws-cdk-lib";
import * as ssm from "aws-cdk-lib/aws-ssm";
import * as waf from "aws-cdk-lib/aws-wafv2";
import { Construct } from "constructs";

export class ApNortheast1Stack extends cdk.Stack {
  distribution: cdk.aws_cloudfront.Distribution;
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);
    
    // IP Set
    const NULL_IP_LIST:string[] = [];
    const allowIpSetForMaintenanceName = 'allow-ip-set-for-maintenance';
    const allowIpSetForMaintenance = new waf.CfnIPSet(
      this,
      "AllowIpSetForMaintenance",
      {
        name: allowIpSetForMaintenanceName,
        scope: "REGIONAL",
        ipAddressVersion: "IPV4",
        addresses: NULL_IP_LIST,
      }
    );

    // Rules
    const blockHighRateAccessRuleName = "block-high-rate-access-rule";
    const blockHighRateAccessRule: waf.CfnWebACL.RuleProperty = {
      priority: 100,
      name: blockHighRateAccessRuleName,
      action: { block: {} },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: '${blockHighRateAccessRuleName}',
      },
      statement: {
        rateBasedStatement: {
          aggregateKeyType: "IP",
          limit: 1000, 
        },
      },
    };

    // RuleGroup
    const defaultRuleGroupName = 'default-rule-group';
    const defaultRuleGroup = new waf.CfnRuleGroup(this, "DefaultRuleGroup", {
      name: defaultRuleGroupName,
      scope: "REGIONAL",
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: defaultRuleGroupName,
      },
      capacity: 10,
      rules: [blockHighRateAccessRule]
    });
    const maintenanceRuleGroupName = 'maintenance-rule-group';
    const maintenanceRuleGroup = new waf.CfnRuleGroup(
      this,
      "MaintenanceRuleGroup",
      {
        name: maintenanceRuleGroupName,
        scope: "REGIONAL",
        visibilityConfig: {
          cloudWatchMetricsEnabled: true,
          sampledRequestsEnabled: true,
          metricName: maintenanceRuleGroupName,
        },
        capacity: 10,
        rules:[]
      }
    );

    // WebACL
    const webACLName = '${restApi.restApiName}-web-acl';
    const webACL = new waf.CfnWebACL(this, "WebAcl", {
      name: webACLName,
      scope: "REGIONAL",
      defaultAction: {
        allow: {},
      },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: '${webACLName}',
      },
      rules: [
        {
          name: 'default-rule-group-reference',
          priority: 100,
          overrideAction: {
            none: {},
          },
          statement: {
            ruleGroupReferenceStatement: {
              arn: defaultRuleGroup.attrArn,
            },
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: 'default-rule-group-reference',
          },
        },
        {
          name: 'maintenance-rule-group-reference',
          priority: 200,
          overrideAction: {
            none: {},
          },
          statement: {
            ruleGroupReferenceStatement: {
              arn: maintenanceRuleGroup.attrArn,
            },
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: 'maintenance-rule-group-reference',
          },
        },
      ],
    });
    
    // メンテナンスに入る際、start_maintenance_wafを実行するためにarnをパラメータストアに保存
    new ssm.StringParameter(this, "AllowIpSetForMaintenanceArnParameter", {
      parameterName: '/allow-ip-set-for-maintenance-arn',
      stringValue: allowIpSetForMaintenance.attrArn,
    });
    new ssm.StringParameter(this, "MaintenanceRuleGroupArnParameter", {
      parameterName: '/maintenance-rule-group-arn',
      stringValue: maintenanceRuleGroup.attrArn,
    });
    new ssm.StringParameter(this, "WebAclArnParameter", {
      parameterName: '/web-acl-arn',
      stringValue: webACL.attrArn,
    });

  }
}
```

# 実装したbash
## 処理の流れ
bashの処理の詳細については以下の通りです。
### start
- パラメータ
  - $1 WhiteIPList
- フロー
  - IPSetARN取得
    - $1のIPアドレスがすでに追加されていたら処理終了
  - IPSetにIPアドレス追加
  - メンテナンスルールグループを取得
    - すでにメンテナンスグループにルールがあったら処理終了
  - ルール作成・更新
    - SSMの保存されたメンテナンスルールARNで既に適用されているか確認
      - 適用されていたらIPセットにIPアドレスを追加して中止。されていなかったら続ける
  - default-rule-groupを削除し、maintenance-rule-groupを適用
### end
- パラメータ
  - なし
- フロー
  - メンテナンスグループの中身を空にする
    - メンテナンスルールグループの中身が既に空の場合は処理終了
  - WebACLデータ取得して、カスタムレスポンスを削除する
  - IPSetのARNを取得して、IPSetからIPアドレスを全て削除

## コード
### start

```bash:'start_maintenance.bash'
#!/usr/bin/env bash
set -eux

# 必要な情報をセット
IP_WHITE_LIST=$3 # メンテナンス中に許可するIPアドレス e.g. 0.0.0.0/32,0.0.0.1/32 
SCOPE="REAGIONAL"
REGION="ap-northeast-1"

# $1のIPがすでにあるかの確認
check_ip_in_ipset() {
  # IP_WHITE_LISTを配列に分割
  IFS=',' read -ra IP_WHITE_LIST_ARRAY <<< "$IP_WHITE_LIST"
  # IPセットのARNを取得
  local ip_set_arn_param="/$allow-ip-set-for-maintenance-arn"
  IP_SET_ARN=$(aws ssm get-parameter --name "$ip_set_arn_param" --region "$REGION" --query 'Parameter.Value' --output text)
  IP_SET_NAME=$(echo "$IP_SET_ARN" | cut -d'/' -f3)
  IP_SET_ID=$(echo "$IP_SET_ARN" | cut -d'/' -f4)

  local current_ips=$(aws wafv2 get-ip-set --id "$IP_SET_ID" --scope "$SCOPE" --region "$REGION" --name "$IP_SET_NAME" --output json | jq -r '.IPSet.Addresses[]')
  
  for ip in "${IP_WHITE_LIST_ARRAY[@]}"; do
    if echo "$current_ips" | grep -q "$ip"; then
      echo "Error: IP address $ip is already present in IP set. Exiting."
      exit 0
    fi
  done
}

# IP SetにIPアドレスを追加
add_ip_to_ipset() {
  # IPセット内の現在のIPアドレスのリストを取得
  local current_ips=$(aws wafv2 get-ip-set --id "$IP_SET_ID" --scope "$SCOPE" --region "$REGION" --name "$IP_SET_NAME" --output json | jq -r '.IPSet.Addresses')
  local ip_set_lock_token=$(aws wafv2 get-ip-set --id "$IP_SET_ID" --scope "$SCOPE" --region "$REGION" --name "$IP_SET_NAME" --output json | jq -r '.LockToken')
  
  # 追加のIPアドレスがあればIPセットに追加する
  for ip in "${IP_WHITE_LIST_ARRAY[@]}"; do
      current_ips=$(echo "$current_ips" | jq --arg ip "$ip" '. + [$ip]')
  done

  aws wafv2 update-ip-set \
    --name "$IP_SET_NAME" \
    --scope "$SCOPE" \
    --region "$REGION" \
    --id "$IP_SET_ID" \
    --addresses "$current_ips" \
    --lock-token "$ip_set_lock_token"
}

# maintenanceGroupにIPSetのルールを追加し、update-rule-groupを実行
update_maintenance_rule_group(){
    local maintenance_rule_group_arn_param="/maintenance-rule-group-arn"
    local maintenance_rule_group_arn=$(aws ssm get-parameter --name "$maintenance_rule_group_arn_param" --region "$REGION" --query 'Parameter.Value' --output text)
    local maintenance_rule_group_name=$(echo "$maintenance_rule_group_arn" | cut -d'/' -f3)
    local maintenance_rule_group_id=$(echo "$maintenance_rule_group_arn" | cut -d'/' -f4)
    local rule_group=$(aws wafv2 get-rule-group --id "$maintenance_rule_group_id" --name "$maintenance_rule_group_name" --scope "$SCOPE" --region "$REGION")

    # メンテナンスルールが既に適用されているか確認
    if  echo "$rule_group" | jq -r '.RuleGroup.Rules[].Statement.IPSetReferenceStatement.ARN' | grep -q "$IP_SET_ARN"; then
        echo "Error: The system is already in maintenance mode with ${maintenance_rule_group_name} applied." >&2
        exit 1
    fi

    # ルールグループを更新する
    local maintenance_rule_group_lock_token=$(echo "$rule_group" | jq -r '.LockToken')
    local updated_rules=$(echo "$updated_rules" | jq --arg arn "$IP_SET_ARN" \
    '.Statement.IPSetReferenceStatement.ARN = $arn' \
    ./maintenance/rules/maintenance_mode_rule.json)

    aws wafv2 update-rule-group \
    --name "$maintenance_rule_group_name" \
    --scope "$SCOPE" \
    --region "$REGION" \
    --lock-token "$maintenance_rule_group_lock_token" \
    --id "$maintenance_rule_group_id" \
    --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=MaintenanceModeRuleGroupMetrics \
    --rules "$updated_rules"
}

# WebACLにカスタムレスポンスを追加
add_custom_response_to_web_acl() {
    local web_acl_arn=$(aws ssm get-parameter --name "$web_acl_arn_param" --region "$REGION" --query 'Parameter.Value' --output text)
    local web_acl_name=$(echo "$web_acl_arn" | cut -d'/' -f3)
    local web_acl_id=$(echo "$web_acl_arn" | cut -d'/' -f4)
    local web_acl_current_rules=$(aws wafv2 get-web-acl --id "$web_acl_id" --name "$web_acl_name" --scope "$SCOPE" --region "$REGION" | jq -r '.WebACL.Rules')
    local web_acl_lock_token=$(aws wafv2 get-web-acl --id "$web_acl_id" --name "$web_acl_name" --scope "$SCOPE" --region "$REGION" | jq -r '.LockToken')

    aws wafv2 update-web-acl \
    --id "$web_acl_id" \
    --scope "$SCOPE" \
    --region "$REGION" \
    --name "$web_acl_name" \
    --default-action '{"Block": {"CustomResponse": {"CustomResponseBodyKey": "MAINTENANCE", "ResponseCode": 503}}}' \
    --visibility-config SampledRequestsEnabled=false,CloudWatchMetricsEnabled=false,MetricName="$web_acl_name" \
    --custom-response-bodies ./maintenance/jsons/custom_response_bodies.json \
    --rules "$web_acl_current_rules" \
    --lock-token "$web_acl_lock_token"
}

check_ip_in_ipset
add_ip_to_ipset
update_maintenance_rule_group
add_custom_response_to_web_acl

echo "Maintenance mode rule added."

```
### end
```bash: 'start_maintenance.bash'
#!/usr/bin/env bash
set -eux

# 必要な情報をセット
SCOPE="REGIONAL"
REGION="ap-northeast-1"

# メンテナンスグループの中身を空にする
clear_maintenance_rule_group() {
    local maintenance_rule_group_arn_param="/maintenance-rule-group-arn"
    local maintenance_rule_group_arn=$(aws ssm get-parameter --name "$maintenance_rule_group_arn_param" --region "$REGION" --query 'Parameter.Value' --output text)
    local maintenance_rule_group_name=$(echo "$maintenance_rule_group_arn" | cut -d'/' -f3)
    local maintenance_rule_group_id=$(echo "$maintenance_rule_group_arn" | cut -d'/' -f4)
    local maintenance_rule_group_lock_token=$(aws wafv2 get-rule-group --id "$maintenance_rule_group_id" --name "$maintenance_rule_group_name" --scope "$SCOPE" --region "$REGION" --output json | jq -r '.LockToken')
    local rule_group=$(aws wafv2 get-rule-group --id "$maintenance_rule_group_id" --name "$maintenance_rule_group_name" --scope "$SCOPE" --region "$REGION")

    # メンテナンスルールグループの中身が既に空の場合は処理終了
    if  echo "$rule_group" | jq -r '.RuleGroup.Rules' | grep -q '^\[\]$'; then
        echo "Error: Maintenance mode is not started." >&2
        exit 1
    fi
    
    aws wafv2 update-rule-group \
        --id "$maintenance_rule_group_id" \
        --name "$maintenance_rule_group_name" \
        --scope "$SCOPE" \
        --region "$REGION" \
        --lock-token "$maintenance_rule_group_lock_token" \
        --rules '[]' \
        --visibility-config SampledRequestsEnabled=false,CloudWatchMetricsEnabled=false,MetricName="$maintenance_rule_group_name"
}

# WebACLデータ取得して、カスタムレスポンスを削除する
clear_custom_response() {
    local web_acl_arn_param="/web-acl-arn"
    local web_acl_arn=$(aws ssm get-parameter --name "$web_acl_arn_param" --region "$REGION" --query 'Parameter.Value' --output text)
    local web_acl_name=$(echo "$web_acl_arn" | cut -d'/' -f3)
    local web_acl_id=$(echo "$web_acl_arn" | cut -d'/' -f4)
    local web_acl_current_rules=$(aws wafv2 get-web-acl --id "$web_acl_id" --name "$web_acl_name" --scope "$SCOPE" --region "$REGION" | jq -c '.WebACL.Rules')
    local web_acl_lock_token=$(aws wafv2 get-web-acl --id "$web_acl_id" --scope "$SCOPE" --region "$REGION" --name "$web_acl_name" --output json | jq -r '.LockToken')
    
    aws wafv2 update-web-acl \
        --id $web_acl_id \
        --scope $SCOPE \
        --region $REGION \
        --name $web_acl_name \
        --default-action '{"Allow": {}}' \
        --visibility-config SampledRequestsEnabled=false,CloudWatchMetricsEnabled=false,MetricName="$web_acl_name" \
        --rules $web_acl_current_rules \
        --lock-token $web_acl_lock_token
}

# IPSetからIPアドレスを全て削除
clear_ip_set() {
    # IPSetのARNを取得
    local ip_set_arn_param="/allow-ip-set-for-maintenance-arn"
    local ip_set_arn=$(aws ssm get-parameter --name "$ip_set_arn_param" --region "$REGION" --query 'Parameter.Value' --output text)
    local ip_set_name=$(echo "$ip_set_arn" | cut -d'/' -f3)
    local ip_set_id=$(echo "$ip_set_arn" | cut -d'/' -f4)

    local ip_set_lock_token=$(aws wafv2 get-ip-set --id "$ip_set_id" --scope "$SCOPE" --region "$REGION" --name "$ip_set_name" --output json | jq -r '.LockToken')

    aws wafv2 update-ip-set \
        --scope "$SCOPE" \
        --id "$ip_set_id" \
        --lock-token "$ip_set_lock_token" \
        --name "$ip_set_name" \
        --addresses '[]' \
        --region "$REGION"
}

# メインフロー
clear_maintenance_rule_group
clear_custom_response
clear_ip_set

echo "Maintenance mode rule deleted."

```
### jsons

```bash: 'custom_response_bodies.json'
{
  "MAINTENANCE": {
    "Content": "{\"message\":\"The service is under maintenance\"}",
    "ContentType": "APPLICATION_JSON"
  }
}

```

```bash: 'maintenance_mode_rule.json'
{
  "Name": "allow-ip-address-rule",
  "Priority": 0,
  "Statement": {
      "IPSetReferenceStatement": {
        "ARN": "{{IP_SET_ARN}}"
      }
  },
  "Action": {
      "Allow": {}
  },
  "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "allow-ip-address-rule"
  }
}
```

# 参考
- [AWS/WAFでメンテナンスページを表示する](https://zenn.dev/not75743/articles/c65556b642a599)
- [AWS 環境でメンテナンスモードを実現する](https://zenn.dev/htnk128/articles/97e4821554b1ed#%E5%AE%9F%E8%A3%85%E5%86%85%E5%AE%B9)