Konnectの証明書の期限をLambdaで監視し、CloudWatch経由でメール通知する

API Gatewayの1つであるKong GatewayにはSaaS版があり、Konnectという名前で展開されている。
このKonnectでは証明書を登録し、管理する機能がある。
![20240916103714.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/e44681f1-958c-2b26-9c5e-98fe6911cf27.png)

Konnectはこの証明書の有効期限を監視・通知する機能を'24/9時点では提供していない。
しかし、APIによる期限の取得は可能となっているため、ユーザ自身が作り込むことで監視・通知することが出来るようになっている。
今回はこれを使って証明書の有効期限をLambdaで取得し、CloudWatchにメトリクスとして登録してアラート通知を受け取るようにしてみる。

## Konnect APIの基礎
KonnectはAPIによる操作も可能で、操作にはPersonal Access Token（PAT)を使ってアクセスする。公式の説明ページは[こちら](https://docs.konghq.com/konnect/api/)。
PATはKonnectにログイン後、右上のユーザのアイコンから`Personal Access Tokens`から発行できる。

証明書は各Control Plane（CP）のAPIエンドポイントを参照することで確認できる。
エンドポイントの仕様は以下となる。
- [List all Certificates](https://docs.konghq.com/konnect/api/control-plane-configuration/latest/#/Certificates/list-certificate)
- [List all CA Certificates](https://docs.konghq.com/konnect/api/control-plane-configuration/latest/#/CA%20Certificates/list-ca_certificate)

仕様に従って試しに叩いてみる。
叩く上で`controlPlaneID`が必要となるが、これはKonnectでCP選択後にOverviewから確認できる
![20240916105241.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/d4a0f1bc-8b84-b222-847a-55e727ad7595.png)


CPのIDとPATを環境変数に設定する。
```sh
CP_ID="baa71427-4848-4d59-bca4-1fb276a2fec2"
KONNECT_TOKEN=kpat_ER1WQ0rnLX8xxxx
```
curlでAPIのエンドポイントにアクセスする。
```sh
curl -H "Authorization: Bearer $KONNECT_TOKEN" https://us.api.konghq.com/v2/control-planes/${CP_ID}/core-entities/certificates | jq .
```
以下のようなJSONフォーマットの結果が返ってくる。
```json
{
  "data": [
    {
      "cert": "-----BEGIN CERTIFICATE-----\nMIIDk
      ：（省略）
      "metadata": {
        "dns_names": [
          "anatropia.mydomain",
          "bromobenzene.mydomain"
        ],
        "expiry": "1748741716",
        "issuer": "CN=rootca",
　　　　：（省略）
```
`data`は配列となっていて、証明書がそれぞれ格納されており、`expiry`に期限(UNIX時間)が入っている。
```sh
$ date -r 1748741716
2025年 6月 1日 日曜日 10時35分16秒 JST
```
なので `expiry` - 今日のUNIX時間 = 有効期限 をメトリクスとして登録すれば、証明書の期限をCloud Watch上で監視できるようになる。

## 実装
ここでは以下のように実装する
- Lambda（Python）で実装する
  - PATはSecrets Managerに登録し、Lambdaから参照する
  - CPのIDはイベントとして1つだけ渡す
  - 証明書の期限はCP上にあるもの全てをメトリクスとして登録する
- Lambdaの呼び出しはEventBridgeで日次で呼び出す
- 全ての設定はCLIで行う

### Secrets ManagerへPATを登録
先ほどPATを設定した環境変数`KONNECT_TOKEN`を使ってSecrets Managerに`konnect-token`という名前でPATを登録する。
```sh
aws secretsmanager create-secret \
    --name konnect-token \
    --secret-string "{\"konnect-token\":\"${KONNECT_TOKEN}\"}"
```
これはLambdaがKonnectとアクセスする際に利用する。

### 権限の設定
最初にLambdaの実行ロールを作成し、ポリシーをアタッチする。
```sh
aws iam create-role --role-name lambda-ex --assume-role-policy-document '{"Version": "2012-10-17","Statement": [{ "Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}'
```
```sh
aws iam attach-role-policy --role-name lambda-ex --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
```
今回はPATをSecrets Managerから参照するので、SecretManagerから参照できるようポリシーを追加でアタッチする。

SecretのARNを取得する。
```sh
aws secretsmanager describe-secret --secret-id konnect-token
```
取得したARNに対しポリシーを設定する
```sh
aws iam put-role-policy --role-name lambda-ex \
    --policy-name SecretsManagerAccess \
    --policy-document '{
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Action": "secretsmanager:GetSecretValue",
                "Resource": "arn:aws:secretsmanager:us-east-1:<アカウントID>:secret:konnect-token-<文字列>"
            }
        ]
    }'
```
LambdaからCloudWatchにメトリクスをPushするためのポリシーを付与する。
```sh
cat <<EOF > ./cloudwatch-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "cloudwatch:PutMetricData",
            "Resource": "*"
        }
    ]
}
EOF
```
```sh
aws iam create-policy \
    --policy-name LambdaCloudWatchPutMetricAccess \
    --policy-document file://cloudwatch-policy.json
```
```sh
aws iam attach-role-policy \
    --role-name lambda-ex \
    --policy-arn arn:aws:iam::<アカウントID>:policy/LambdaCloudWatchPutMetricAccess
```


### コードの作成
Lambdaで実行するコードを作成する。
適当なディレクトリを作成する。
```sh
mkdir kong_cert_monitor
cd kong_cert_monitor
```
コードを作成する。
```python:lambda_function.py
cat <<EOF > ./lambda_function.py
import requests
import json
from datetime import datetime
import boto3
from botocore.exceptions import ClientError


def get_secret():
    secret_name = "konnect-token"
    region_name = "us-east-1"

    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager',
        region_name=region_name
    )

    try:
        get_secret_value_response = client.get_secret_value(
            SecretId=secret_name
        )
    except ClientError as e:
        raise e

    print(f"Secret String: {get_secret_value_response['SecretString']}")
    secret_dict = json.loads(get_secret_value_response['SecretString'])

    return secret_dict.get(secret_name)

def get_certificates(api_token, cp_id, certificate_type="certificates"):
    url = f"https://us.api.konghq.com/v2/control-planes/{cp_id}/core-entities/{certificate_type}"
    headers = {
        "Authorization": f"Bearer {api_token}",
        "Content-Type": "application/json"
    }
    print(f"Requesting {certificate_type} from {url}")
    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        print(f"{certificate_type.capitalize()} data retrieved: {response.json()}")
    except requests.exceptions.RequestException as e:
        print(f"Error retrieving {certificate_type}: {e}")
        print(f"Bearer {api_token}")
        raise e

    return response.json()

def check_certificate_expiry(cert_data):
    expiry_dates = []
    for cert in cert_data['data']:
        try:
            expiry_timestamp = int(cert['metadata']['expiry'])
            expiry_date = datetime.utcfromtimestamp(expiry_timestamp)
            certificate_id = cert['id']
            print(f"Certificate {certificate_id} expiry date: {expiry_date}")
            expiry_dates.append((certificate_id, expiry_date))
        except KeyError as e:
            print(f"Error extracting expiry date for certificate {cert['id']}: {e}")
            continue
    return expiry_dates

def put_cloudwatch_metric(expiry_date, certificate_id, metric_name='CertificateExpiry'):
    cloudwatch = boto3.client('cloudwatch')
    try:
        print(f"Sending metric data to CloudWatch for certificate {certificate_id} with metric name {metric_name}")
        cloudwatch.put_metric_data(
            Namespace='KongCertificates',
            MetricData=[
                {
                    'MetricName': metric_name,
                    'Dimensions': [
                        {
                            'Name': 'CertificateId',
                            'Value': certificate_id
                        },
                    ],
                    'Timestamp': datetime.utcnow(),
                    'Value': (expiry_date - datetime.utcnow()).days,
                },
            ]
        )
        print(f"Metric data sent to CloudWatch for certificate {certificate_id} with metric name {metric_name}")
    except ClientError as e:
        print(f"Error sending metric data to CloudWatch for certificate {certificate_id}: {e}")
        raise e

def lambda_handler(event, context):
    try:
        print("Starting Lambda function")
        cp_id = event.get('cp_id')
        if not cp_id:
            print("Error: cp_id is not provided")
            return 
        api_token = get_secret()
        
        # 通常の証明書の取得と処理
        cert_data = get_certificates(api_token, cp_id, certificate_type="certificates")
        expiry_dates = check_certificate_expiry(cert_data)
        for certificate_id, expiry_date in expiry_dates:
            put_cloudwatch_metric(expiry_date, certificate_id)

        # CA証明書の取得と処理
        ca_cert_data = get_certificates(api_token, cp_id, certificate_type="ca_certificates")
        ca_expiry_dates = check_certificate_expiry(ca_cert_data)
        for certificate_id, expiry_date in ca_expiry_dates:
            put_cloudwatch_metric(expiry_date, certificate_id, metric_name='CA CertificateExpiry')

        print("Lambda function completed successfully")
    except Exception as e:
        print(f"Error in lambda_handler: {e}")
        raise e
EOF
```
実行に必要なファイルをインストールする。
```sh
pip install requests -t .
```
なお、自分の環境だと以下のエラーが出た。
```sh
[ERROR] Runtime.ImportModuleError: Unable to import module 'lambda_function': urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'OpenSSL 1.0.2k-fips  26 Jan 2017'. See: https://github.com/urllib3/urllib3/issues/2168
Traceback (most recent call last):
```
なので追加でurllibのバージョンを固定でインストールした。
```sh
pip install --upgrade urllib3==1.26.16 -t .
```
ファイル一式をzipに固めてLambdaにKongCertMonitorという名前で登録する。
```sh
zip -r ../kong_cert_monitor.zip .
```
Lambdaの関数を作成する。
```sh
aws lambda create-function \
    --function-name KongCertMonitor \
    --runtime python3.8 \
    --role arn:aws:iam::<アカウントID>:role/lambda-ex \
    --handler lambda_function.lambda_handler \
    --zip-file fileb://../kong_cert_monitor.zip \
    --no-cli-pager
```
なお、2回目以降は以下で更新する。
```sh
aws lambda update-function-code \
    --function-name KongCertMonitor \
    --zip-file fileb://../kong_cert_monitor.zip \ --no-cli-pager
```

### 動作確認
実行に3秒以上掛かることがあるので、デフォルトのタイムアウト値から30秒に変更する。
```sh
aws lambda update-function-configuration \
    --function-name KongCertMonitor \
    --timeout 30 --no-cli-pager
```
`aws lambda invoke`を使ってテストする。
```sh
PAYLOAD=$(echo -n '{"cp_id": "'"${CP_ID}"'"}' | base64)
aws lambda invoke \
    --function-name KongCertMonitor \
    --payload "$PAYLOAD" \
    output.txt
```
問題なければ以下のような出力が得られる。
```json
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
```
ログも見てみる。
最新のログストリーム名を取得する。
```sh
LOG_STREAM=$(aws logs describe-log-streams   --log-group-name /aws/lambda/KongCertMonitor   --query "logStreams[].[logStreamName]" --output text | tail -n 1)
```
ログを取得する。
```sh
aws logs get-log-events \
  --log-group-name /aws/lambda/KongCertMonitor \
  --log-stream-name "$LOG_STREAM" \
  --query "events[].[message]" \
  --output text
```
証明書が上手く取得できて飛ばせている様子が確認できるはずだ。

Cloud WatchのUIからもメトリクスが確認できる。反映に少し時間が掛かる気がするので、すぐに表示できない場合は10分程度待ってから見てみると確認できる。
![20240916120309.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/247adf60-cc1b-9713-debb-4f5ae031f2c0.png)


### 日次で実行させる
日次で実行するのにEventBridgeを利用する。
まず日次実行のためのイベントを作成する。
```sh
aws events put-rule \
    --name DailyKongCertMonitor \
    --schedule-expression "cron(0 0 * * ? *)" \
    --description "Run Lambda function to monitor Kong certificates every day at midnight"
```
`--schedule-expression "cron(0 0 * * ? *)"`で毎日UTCで0時（日本時間で午前9時）に実行するよう指定している。
次にLambdaに対して先ほど作成したイベントからのアクセスを許可する。
```sh
aws lambda add-permission \
    --function-name KongCertMonitor \
    --statement-id DailyKongCertMonitorPermission \
    --action "lambda:InvokeFunction" \
    --principal events.amazonaws.com \
    --source-arn arn:aws:events:<リージョン>:<アカウントID>:rule/DailyKongCertMonitor
```
先ほど作成したイベントとLambda関数を紐づける。

```sh
ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --no-cli-pager --output text)
cat <<EOF > ../input.json
{
  "Rule": "DailyKongCertMonitor",
  "Targets": [
    {
      "Id": "1",
      "Arn": "arn:aws:lambda:$AWS_REGION:359304582046:function:KongCertMonitor",
      "Input": "{\"cp_id\": \"$CP_ID\"}"
    }
  ]
}
EOF
aws events put-targets --cli-input-json file://../input.json
```

### アラートを設定する

最初にアラートに関するSNSトピックを作成し、自身のメールアドレスを購読対象とする。
```sh
MY_MAIL=xxx@xxx.xxx
aws sns create-topic --name KonnectCertAlarm
```
```sh
aws sns subscribe \
    --topic-arn arn:aws:sns:${AWS_REGION}:${ACCOUNT_ID}:KonnectCertAlarm \
    --protocol email \
    --notification-endpoint $MY_MAIL
```
コマンド実行後、購読するかの確認のメールが届くので、Confirm subscriptionをクリックして購読する。
次にアラームを作成し、作成したSNSトピックと紐づける。
ここでは`aws cloudwatch put-metric-alarm`を使って証明書ごとにアラームを1つ作成する。
ただ、何度も呼ぶのは面倒なので、アラームを作成するためのスクリプトを用意する。
```sh
cd ..
cat <<'EOF' > ./set_cert_alerm.sh
#!/bin/bash

if [ "$#" -lt 2 ]; then
    echo "Usage: $0 <MetricName> <CertId1> [<CertId2> ... <CertIdN>]"
    exit 1
fi

METRIC_NAME=$1
shift
CERT_IDS=("$@")

AWS_REGION=${AWS_REGION:-us-east-1}
ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --no-cli-pager --output text)

for CERT_ID in "${CERT_IDS[@]}"; do
    echo "Creating alarm for Certificate ID: $CERT_ID"
    
    aws cloudwatch put-metric-alarm \
        --alarm-name "${METRIC_NAME} Below 300 for $CERT_ID" \
        --metric-name "$METRIC_NAME" \
        --namespace "KongCertificates" \
        --statistic "Minimum" \
        --treat-missing-data "notBreaching" \
        --threshold 300 \
        --comparison-operator "LessThanThreshold" \
        --evaluation-periods 1 \
        --period 86400 \
        --dimensions "Name=CertificateId,Value=$CERT_ID" \
        --alarm-actions "arn:aws:sns:${AWS_REGION}:${ACCOUNT_ID}:KonnectCertAlarm"

    if [ $? -eq 0 ]; then
        echo "Alarm created successfully for Certificate ID: $CERT_ID"
    else
        echo "Failed to create alarm for Certificate ID: $CERT_ID"
    fi
done
EOF
chmod +x ./set_cert_alerm.sh
```
引数の詳細な説明は省くが、`--threshold 300`にしているのはメトリクスで一番小さいものが257（証明書が切れるまで残り257日）となっていて、これを使ってアラートが上がることを確認したいからである。
この値については環境に合わせて修正するとよい。
なおコマンド仕様としては最初の引数でメトリクス名を渡し、2番目以降の引数に証明書のID（Konnectから取得できるID）を渡して設定する。
スクリプトを使ってアラームを作成する。最初に通常のCertificateに対するアラームを作成する。
```sh
./set_cert_alerm.sh "CertificateExpiry" 665c146b-5138-482b-8f75-4148c413a191 8042fd85-7ff5-44fb-ae7f-879f0c8f45a1
```
次にCA Certificateに対するアラームを作成する。
```sh
./set_cert_alerm.sh "CA CertificateExpiry" 6f41b3de-ca61-42c1-a63e-68b2249195fc 9eb569ac-81ce-4308-87c0-6aedf0e508d2
```
なお、ここで動作確認をしたいのだが、一時的にEventBridgeの実行間隔を5分間隔と早めてメトリクスが溜まるようにした。

アラーム作成後、CloudWatchのコンソールを確認するとアラームが確認でき、発火したことも確認できる。

![20240916152357.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/6e74d892-c6a1-6d46-2b79-4fe0d1886a4e.png)

メールでも以下のような通知が届いていることが確認できた。

> You are receiving this email because your Amazon CloudWatch Alarm "CertificateExpiry Below 300 for 665c146b-5138-482b-8f75-4148c413a191" in the US East (N. Virginia) region has entered the ALARM state, because "Threshold Crossed: 1 datapoint [257.0 (16/09/24 06:16:00)] was less than the threshold (300.0)." at "Monday 16 September, 2024 06:21:55 UTC".