0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-09-16

API Gatewayの1つであるKong GatewayにはSaaS版があり、Konnectという名前で展開されている。
このKonnectでは証明書を登録し、管理する機能がある。
20240916103714.png

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

Konnect APIの基礎

KonnectはAPIによる操作も可能で、操作にはPersonal Access Token(PAT)を使ってアクセスする。公式の説明ページはこちら
PATはKonnectにログイン後、右上のユーザのアイコンからPersonal Access Tokensから発行できる。

証明書は各Control Plane(CP)のAPIエンドポイントを参照することで確認できる。
エンドポイントの仕様は以下となる。

仕様に従って試しに叩いてみる。
叩く上でcontrolPlaneIDが必要となるが、これはKonnectでCP選択後にOverviewから確認できる
20240916105241.png

CPのIDとPATを環境変数に設定する。

CP_ID="baa71427-4848-4d59-bca4-1fb276a2fec2"
KONNECT_TOKEN=kpat_ER1WQ0rnLX8xxxx

curlでAPIのエンドポイントにアクセスする。

curl -H "Authorization: Bearer $KONNECT_TOKEN" https://us.api.konghq.com/v2/control-planes/${CP_ID}/core-entities/certificates | jq .

以下のようなJSONフォーマットの結果が返ってくる。

{
  "data": [
    {
      "cert": "-----BEGIN CERTIFICATE-----\nMIIDk
      :(省略)
      "metadata": {
        "dns_names": [
          "anatropia.mydomain",
          "bromobenzene.mydomain"
        ],
        "expiry": "1748741716",
        "issuer": "CN=rootca",
    :(省略)

dataは配列となっていて、証明書がそれぞれ格納されており、expiryに期限(UNIX時間)が入っている。

$ 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を登録する。

aws secretsmanager create-secret \
    --name konnect-token \
    --secret-string "{\"konnect-token\":\"${KONNECT_TOKEN}\"}"

これはLambdaがKonnectとアクセスする際に利用する。

権限の設定

最初にLambdaの実行ロールを作成し、ポリシーをアタッチする。

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"}]}'
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を取得する。

aws secretsmanager describe-secret --secret-id konnect-token

取得したARNに対しポリシーを設定する

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するためのポリシーを付与する。

cat <<EOF > ./cloudwatch-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "cloudwatch:PutMetricData",
            "Resource": "*"
        }
    ]
}
EOF
aws iam create-policy \
    --policy-name LambdaCloudWatchPutMetricAccess \
    --policy-document file://cloudwatch-policy.json
aws iam attach-role-policy \
    --role-name lambda-ex \
    --policy-arn arn:aws:iam::<アカウントID>:policy/LambdaCloudWatchPutMetricAccess

コードの作成

Lambdaで実行するコードを作成する。
適当なディレクトリを作成する。

mkdir kong_cert_monitor
cd kong_cert_monitor

コードを作成する。

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

実行に必要なファイルをインストールする。

pip install requests -t .

なお、自分の環境だと以下のエラーが出た。

[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のバージョンを固定でインストールした。

pip install --upgrade urllib3==1.26.16 -t .

ファイル一式をzipに固めてLambdaにKongCertMonitorという名前で登録する。

zip -r ../kong_cert_monitor.zip .

Lambdaの関数を作成する。

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回目以降は以下で更新する。

aws lambda update-function-code \
    --function-name KongCertMonitor \
    --zip-file fileb://../kong_cert_monitor.zip \ --no-cli-pager

動作確認

実行に3秒以上掛かることがあるので、デフォルトのタイムアウト値から30秒に変更する。

aws lambda update-function-configuration \
    --function-name KongCertMonitor \
    --timeout 30 --no-cli-pager

aws lambda invokeを使ってテストする。

PAYLOAD=$(echo -n '{"cp_id": "'"${CP_ID}"'"}' | base64)
aws lambda invoke \
    --function-name KongCertMonitor \
    --payload "$PAYLOAD" \
    output.txt

問題なければ以下のような出力が得られる。

{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}

ログも見てみる。
最新のログストリーム名を取得する。

LOG_STREAM=$(aws logs describe-log-streams   --log-group-name /aws/lambda/KongCertMonitor   --query "logStreams[].[logStreamName]" --output text | tail -n 1)

ログを取得する。

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

日次で実行させる

日次で実行するのにEventBridgeを利用する。
まず日次実行のためのイベントを作成する。

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に対して先ほど作成したイベントからのアクセスを許可する。

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関数を紐づける。

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トピックを作成し、自身のメールアドレスを購読対象とする。

MY_MAIL=xxx@xxx.xxx
aws sns create-topic --name KonnectCertAlarm
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つ作成する。
ただ、何度も呼ぶのは面倒なので、アラームを作成するためのスクリプトを用意する。

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に対するアラームを作成する。

./set_cert_alerm.sh "CertificateExpiry" 665c146b-5138-482b-8f75-4148c413a191 8042fd85-7ff5-44fb-ae7f-879f0c8f45a1

次にCA Certificateに対するアラームを作成する。

./set_cert_alerm.sh "CA CertificateExpiry" 6f41b3de-ca61-42c1-a63e-68b2249195fc 9eb569ac-81ce-4308-87c0-6aedf0e508d2

なお、ここで動作確認をしたいのだが、一時的にEventBridgeの実行間隔を5分間隔と早めてメトリクスが溜まるようにした。

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

20240916152357.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".

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?