🔖

【CircleCI】AWS WAFのIPsetをリポジトリで管理する

に公開

はじめに

皆さんはAWS WAFに設定するIPセットをどのように管理していますか?
プロジェクトが大きくなるにつれJoinするメンバーのIPアドレスを多数管理する場合、管理方法についてその最適解に悩むことも少なくないかと思います。

背景として、AWSコンソールから確認できる設定済みのIPアドレスは、アドレスのみのリストですので、実際に何のIPアドレスなのかはこのIPセットだけでは管理出来ません。

ipset01

本記事では、IPアドレスとその用途をセットでテキストベースで管理し、そのファイルをもとにAWS WAFのIPセットの設定を行う流れについて記載いたします。

本記事の留意点

  • タイトルにCircleCIと入れていますが、シェルスクリプトをベースとした手法ですのでGitHub Actionsにも流用可能な方法となっております。
  • AWS WAFのACLの更新に関しては内容触れておりません。
  • 記事で使用しているIPアドレスや名前はサンプルデータですので、実在するものとは一切関係ございません。
  • 既に用意済みのIPセットに対して、IPアドレスリストを更新する方法について記載しています。

IPアドレスをテキストファイルで管理する

以下のように、IPアドレスとカンマ区切りで用途もしくは利用者について記載したテキストファイルで管理します。
このファイルは、AIの力で作成した疑似データです。
余談ですが最初かなり実在しそうな日本人名や会社名が作成されてしまったので、外国人名に作り直しました。

この形式で後述するCI/CDを設定すれば、非エンジニアの方でも追記してIPセットを更新できそうかと思います。

ip-set-sample.txt
45.76.123.10/32, Aetherix LLC - Lars Öberg(DevOps)
13.227.44.101/32, Novara Systems - Amira Khaled(セールス)
34.201.72.59/32, BlueCedar Labs - Diego Márquez(研究)
51.15.201.7/32, Solenium Inc. - Freja Lindström(Remote)
88.198.134.22/32, Vintara Holdings - Marcus V. Hargreaves(CFO)
103.86.55.121/32, Orbio Networks - Anika Müller(ネットワーク)
104.244.42.129/32, Crestlineworks - Pavel Ivanov(エンジニア)
162.159.24.45/32, Nyxoria GmbH - Sofia Rossi(マーケ)
185.199.110.77/32, Halcyon Forge - Kaito Nakamura(コントリビュータ)
143.244.56.12/32, Meridian Arc - Oleg Petrov(サポート)
206.189.34.88/32, Skylane Partners - Amélie Dubois(PM)
174.129.23.5/32, Tesseract Solutions - Hugo Fernandes(顧問)
80.82.77.64/32, Velvet Harbor - Ingrid Sørensen(QA)
54.38.12.201/32, Quivira Tech - Benoît Moreau(バックエンド)
109.74.32.11/32, LumenField Ltd. - Zara Khan(HR)
213.136.89.3/32, AstraVale Corporation - Henrik Jansson(財務)
199.115.31.200/32, Parallax Works - Lucia Romano(デザイナー)
147.75.65.18/32, Emberline Co. - Nikhil Rao(データサイエンティスト)
52.58.23.144/32, Silica Ridge - Marta Kowalska(法務)
...(以下全部で60個)

IPセットの作成

今回は既存のIPセットに対しての更新を行う内容ですので、説明用として先に手動でIPセットを作成しておきます。
名前はipset-auto-update-testとして東京リージョンにしました。

以下の表示は新しいWAFコンソールで表示しています。
ipset02
ちなみに古いWAFコンソールは以下の表示になります。
ipset03

リポジトリに各ファイルを用意

リポジトリには、以下のようなファイル構成で用意します。

% tree -a . -I '.git'
.
├── .circleci
│   └── config.yml
├── ip-set-sample.txt
└── update-ip-set.sh
  • .circleci/config.yml: CircleCIの設定ファイル
  • ip-set-sample.txt: 先ほど記載したIPアドレスとカンマ区切りで用途を書いたテキストファイル
  • update-ip-set.sh: 後述するシェルスクリプト

このシェルスクリプトは単体でも実行できる内容なので、GitHub Actionsなどにも利用可能です。
早速その中身を見ていきましょう。

IPセットを更新するシェルスクリプトの作成

まずは全体のコードを先に掲載します。

update-ip-set.sh
#!/bin/bash

_ipset_id=$1
_ipset_name=$2
_scope=$3
_region=$4
_list_path=$5
_aws_profile=$6

# 0. 引数チェックとリストファイル存在チェック
if [[ -z "$_ipset_id" || -z "$_ipset_name" || -z "$_scope" || -z "$_region" || -z "$_list_path" || -z "$_aws_profile" ]]; then
  echo "Usage: $0 <ipset_id> <ipset_name> <scope> <region> <list_path> <aws_profile>" >&2
  exit 1
fi
if [[ ! -f "$_list_path" ]]; then
  echo "Error: list file '$_list_path' not found." >&2
  exit 1
fi

# 1. file読み込みIPアドレスのリストを作成
ip_addrs=""
while IFS= read -r line || [[ -n $line ]]; do
  # 行から左側の IP/CIDR 部分のみを抽出(カンマ以降を切り捨てる)
  ip="${line%%,*}"
  # 前後の空白を削除
  ip="$(echo "$ip" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
  # 空行はskip
  if [[ -n "$ip" ]]; then
    ip_addrs+="$ip "
  fi
done < "$_list_path"

# 2. LockTokenの取得
lock_token=$(
  aws wafv2 list-ip-sets \
    --scope ${_scope} \
    --region ${_region} \
    --query "IPSets[?Name=='${_ipset_name}'] | [0].LockToken" \
    --output text \
    --profile ${_aws_profile} \
    --no-cli-pager
)
echo $lock_token

# 3. LockTokenを使用してIPセットを更新
aws wafv2 update-ip-set \
  --scope ${_scope} \
  --region ${_region} \
  --id ${_ipset_id} \
  --name ${_ipset_name} \
  --addresses ${ip_addrs} \
  --lock-token ${lock_token} \
  --profile ${_aws_profile} \
  --no-cli-pager


# 4. 更新後の確認: ファイルのリストと実際の設定の差分を確認
local_file=$(mktemp)
tmp_aws=$(mktemp)
trap 'rm -f "$local_file" "$tmp_aws"' EXIT

## 4-1. ファイル側(ip_addrs はスペース区切り)を改行にしてソート
echo "$ip_addrs" | tr ' ' '\n' | sed '/^\s*$/d' | sort -u > "$local_file"

## 4-2. WAF 側の設定を取得し、改行分割してソート
if ! {
  set -o pipefail
  aws wafv2 get-ip-set \
    --scope "${_scope}" \
    --region "${_region}" \
    --id "${_ipset_id}" \
    --name "${_ipset_name}" \
    --query 'IPSet.Addresses' \
    --output text \
    --profile "${_aws_profile}" \
    --no-cli-pager \
  | tr -s '\t ' '\n' \
  | sed '/^\s*$/d' \
  | sort -u > "$tmp_aws"
}; then
  echo "Error: failed to retrieve or process WAF IP set" >&2
  exit 1
fi

## 4-3. 差分の確認
echo "----------------------------------------"
echo "入力ファイルとWAF設定の差分を確認します"
echo "----------------------------------------"
if diff -u "$local_file" "$tmp_aws" >/dev/null; then
  echo "差分なし:更新成功。入力ファイルとWAF設定が一致します。"
  exit 0
else
  echo "差分検出:入力ファイルとWAF設定が一致しません。" >&2
  diff -u "$local_file" "$tmp_aws" >&2
  exit 2
fi

引数の説明

ではセクションごとに内容を確認していきます。

引数としては、以下の5つ〜6つになります。

  • _ipset_id: 更新するIPセットのIDです。コンソール画面のARNの右側に記載されています。
  • _ipset_name: 更新するIPセットの名前です。今回はipset-auto-update-testという名前で作成しました。
  • _scope: CloudFrontディストリビューションなどのグローバルリソースタイプ用かどうかを指定します。グローバルならCLOUDFRONT、そうではないリージョナルならREGIONALを指定します。今回は東京リージョンなのでREGIONALを指定することになります。
  • _region: リージョナルならそのリージョン名を、グローバルならus-east-1を指定します。
  • _list_path: カンマ区切りのIPアドレスを記載したテキストファイルのパスを指定します。
  • _aws_profile: AWSのプロファイル名。これはCI内でプロファイルを指定してsetupしている場合に必要ですが、必須ではありません。

そして必要に応じて一応チェック処理を入れています。

_ipset_id=$1
_ipset_name=$2
_scope=$3
_region=$4
_list_path=$5
_aws_profile=$6

# 0. 引数チェックとリストファイル存在チェック
if [[ -z "$_ipset_id" || -z "$_ipset_name" || -z "$_scope" || -z "$_region" || -z "$_list_path" || -z "$_aws_profile" ]]; then
  echo "Usage: $0 <ipset_id> <ipset_name> <scope> <region> <list_path> <aws_profile>" >&2
  exit 1
fi
if [[ ! -f "$_list_path" ]]; then
  echo "Error: list file '$_list_path' not found." >&2
  exit 1
fi

テキストファイルの読み込み

次にテキストファイルを読み込みます。
テキストファイルは以下の構成で記載し、実際にスクリプトが見るのはカンマまでとします。
ですので用途を自由に記載できます。

{IPアドレス}/{プレフィックス}, {用途}

空行や多少のスペースを許容するかどうかはお任せしますが、今回は多少は許容するパターンで作成してみました。
逆にIPのプレフィクスは必須なのでそのバリデーションを入れるのはありかもしれません。(※ここでは入れておりません)

# 1. file読み込みIPアドレスのリストを作成
ip_addrs=""
while IFS= read -r line || [[ -n $line ]]; do
  # 行から左側の IP/CIDR 部分のみを抽出(カンマ以降を切り捨てる)
  ip="${line%%,*}"
  # 前後の空白を削除
  ip="$(echo "$ip" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
  # 空行はskip
  if [[ -n "$ip" ]]; then
    ip_addrs+="$ip "
  fi
done < "$_list_path"

LockTokenを利用してIPのセットを更新

変数ip_addrsに更新するIPアドレスを用意できたら、実際にaws cliで更新をしていきます。
IPセットを更新する際、AWSはその内容の整合性を保つために、最後に内容を取得した状態に対してtokenを発行し、そのLockTokenが更新に必須となっています。
そのため、まず既存のIPセットをaws wafv2 list-ip-setsコマンドで取得し、LockTokenを取得してからaws wafv2 update-ip-setコマンドで更新する流れになります。

以下は、取得時にLockTokenをqueryし、変数lock_tokenに格納したあとにIPセットを更新しています。

# 2. LockTokenの取得
lock_token=$(
  aws wafv2 list-ip-sets \
    --scope ${_scope} \
    --region ${_region} \
    --query "IPSets[?Name=='${_ipset_name}'] | [0].LockToken" \
    --output text \
    --profile ${_aws_profile} \
    --no-cli-pager
)
echo $lock_token

# 3. LockTokenを使用してIPセットを更新
aws wafv2 update-ip-set \
  --scope ${_scope} \
  --region ${_region} \
  --id ${_ipset_id} \
  --name ${_ipset_name} \
  --addresses ${ip_addrs} \
  --lock-token ${lock_token} \
  --profile ${_aws_profile} \
  --no-cli-pager

設定がきちんと出来たかを確認

ここまでで処理自体は終えて良いのですが、本当に正常にもれなく設定されたか気になる所ですよね。ただ、これを手動でやろうとするとかなり面倒です。
なぜならコンソールで表示されるIPセットの並びと設定元のテキストファイルの並びは違うからです。

そこで、スクリプト内でそれぞれソートしてdiffを取ることで、解決してみました。

# 4. 更新後の確認: ファイルのリストと実際の設定の差分を確認
local_file=$(mktemp)
tmp_aws=$(mktemp)
trap 'rm -f "$local_file" "$tmp_aws"' EXIT

## 4-1. ファイル側(ip_addrs はスペース区切り)を改行にしてソート
echo "$ip_addrs" | tr ' ' '\n' | sed '/^\s*$/d' | sort -u > "$local_file"

## 4-2. WAF 側の設定を取得し、改行分割してソート
if ! {
  set -o pipefail
  aws wafv2 get-ip-set \
    --scope "${_scope}" \
    --region "${_region}" \
    --id "${_ipset_id}" \
    --name "${_ipset_name}" \
    --query 'IPSet.Addresses' \
    --output text \
    --profile "${_aws_profile}" \
    --no-cli-pager \
  | tr -s '\t ' '\n' \
  | sed '/^\s*$/d' \
  | sort -u > "$tmp_aws"
}; then
  echo "Error: failed to retrieve or process WAF IP set" >&2
  exit 1
fi

## 4-3. 差分の確認
echo "----------------------------------------"
echo "入力ファイルとWAF設定の差分を確認します"
echo "----------------------------------------"
if diff -u "$local_file" "$tmp_aws" >/dev/null; then
  echo "差分なし:更新成功。入力ファイルとWAF設定が一致します。"
  exit 0
else
  echo "差分検出:入力ファイルとWAF設定が一致しません。" >&2
  diff -u "$local_file" "$tmp_aws" >&2
  exit 2
fi

ここまでで動作確認する場合

ここまで完了して動作確認したい場合は、引数が多いのでスクリプトを実行するためのスクリプトを作成してみましょう。
私は下記のような感じで実行しました。

#!/bin/bash

_ipset_id=107ea961-48a2-4838-b5a9-f4d0d83c02ae
_ipset_name=ipset-auto-update-test
_scope=REGIONAL
_region=ap-northeast-1
_file_path=./ip-set-sample.txt

./update-ip-set.sh "${_ipset_id}" \
                    "${_ipset_name}" \
                    "${_scope}" \
                    "${_region}" \
                    "${_file_path}" \
                    default

CircleCIの設定

ここからは、先程作成したスクリプトをもとに、CircleCIの設定ファイルを作成していきます。
長く見えるかもしれませんが、先程のスクリプトの引数をparametersとして設定するために行数を使っているだけで、スクリプトを理解すれば特に難しい設定ではないかなと思います。

aws-cli/setupの部分は、oidc認証を使用しています。
ここはkey/sec運用ならそれに合わせて書き方を変更して下さい。

config.yml
version: 2.1

orbs:
  aws-cli: circleci/aws-cli@5.4.1

jobs:
  update-ip-set:
    docker:
      - image: cimg/base:stable
    resource_class: small
    parameters:
      aws-profile:
        type: string
        default: OIDC-user
        description: The AWS profile to use
      ipset-id:
        type: string
        description: The WAF IP set ID
      ipset-name:
        type: string
        description: The WAF IP set name
      scope:
        type: enum
        enum: [CLOUDFRONT, REGIONAL]
        default: CLOUDFRONT
        description: The scope of the WAF IP set
      region:
        type: string
        default: us-east-1
        description: The AWS region
      list-path:
        type: string
        description: Path to the file containing IP addresses
    steps:
      - checkout
      - aws-cli/setup:
          profile_name: << parameters.aws-profile >>
          role_arn: $OIDC_ROLE_ARN
      - run:
          name: Update WAF IP Set
          command: |
            chmod +x ./update-ip-set.sh
            ./update-ip-set.sh \
              << parameters.ipset-id >> \
              << parameters.ipset-name >> \
              << parameters.scope >> \
              << parameters.region >> \
              << parameters.list-path >> \
              << parameters.aws-profile >>

workflows:
  update-waf-ip-set-workflow:
    jobs:
      - update-ip-set:
          name: update-ip-set-tokyo
          ipset-id: 70ed853f-ffe0-4236-b0be-788ff9171d27
          ipset-name: ipset-auto-update-test
          scope: REGIONAL
          region: ap-northeast-1
          list-path: ./ip-set-sample.txt
          context: waf-test

上記を実際に動作してみました。
トータル20秒程度で完了しています。

ipset04

AWSコンソールからも設定が無事反映されていることを確認できました。

ipset05

気になるのはドキュメントにあるmax 50の表記なのですが、
アドレスが50個を超えても問題なく設定出来るんですよね。
これは何の制限になるのでしょうか...?
分かる方いらっしゃいましたらコメントよろしくお願いいたします。

おわりに

AWS WAFのIPセットに焦点を当てて今回記事を書いてみました。
管理方法に正解はないと思いますので、そのひとつとして参考になれば幸いです。

ここまで読んでいただきありがとうございました。

参考

https://docs.aws.amazon.com/cli/latest/reference/wafv2/update-ip-set.html

Discussion