CloudWatch Logs サブスクリプションフィルターを利用して AWS Client VPN の接続を検知してみた
こんにちは、コンサル部の有福です。
前回のブログ記事で AWS Clinet VPN の接続検知を行う方法として、CloudWatch メトリクスを利用した方法を試しましたが、検知に取りこぼしがあったり、検知内容の情報が少ないという課題を抱えた結果となりました。
今回は新たに AWS Client VPN が記録する「接続ログ」をもとに CloudWatch Logs サブスクリプションフィルターを使って接続検知を行う方法を試してみたので、備忘の意味も込めてブログにまとめました。
やりたいこと
AWS Client VPN エンドポイントへの接続があった際に、その接続を検知して通知を出したい
前提条件
- AWS Client VPN を設定済み
-
VPN エンドポイントの設定で「接続ログ記録」が有効
-
VPN 接続時の認証は「相互認証」を使用
-
通知用の Amazon SNS トピックを作成済み
試した方法
今回やろうとしたのは、AWS Client VPN が記録する「接続ログ」をもとに、CloudWatch Logs サブスクリプションフィルターを使って、VPN の接続開始時のログだけを抽出して Lambda に配信し、 Amazon SNS でメール通知を行うという方法です。
イメージ
AWS Client VPN が記録する「接続ログ」について
AWS Client VPN では、エンドユーザーからの接続試行、接続、切断といった接続イベントに関する情報をログとして CloudWatch に記録する「接続ログ記録」の機能を提供しています。
接続ログの内容には、接続に関するステータスや接続元の端末、接続先の VPN エンドポイントなどの情報が含まれます。
この接続ログの内容のうち「connection-attempt-status」という項目が接続リクエストのステータスを示し、この値がsuccessful
となっているものが実際に VPN 接続が開始された時に記録されるログとなります。
CloudWatch Logs サブスクリプションフィルターについて
CloudWatch Logs サブスクリプションフィルターは、CloudWatch Logs に供給されるログイベントを、対応する他の AWS サービスに配信することでログデータのリアルタイム処理を可能にする機能です。
CloudWatch Logs サブスクリプションフィルターの送信先は Kinesis Data Streams、Kinesis Data Firehose、 Lambda のみが対応するため、今回は Lambda を介して Amazon SNS による通知を行うことにしました。
また、CloudWatch Logs サブスクリプションフィルターでは、フィルターパターンを使った配信ログの絞り込みにも対応しています。
今回は VPN 接続が開始された時の接続検知をしたいので、このフィルターパターンを使ったログの絞り込みも行うこととします。
やってみた
Lambda 関数の作成
CloudWatch Logs サブスクリプションフィルターを作成するためには、あらかじめ宛先となる Lambda 関数を用意しておく必要があります。
したがって、Lambda 関数から作成します。
Lambda コンソールを開き、「関数の作成」ボタンをクリックして作成画面に遷移します。
任意の関数名を入力し、設定については以下のとおり設定しました。
- ランタイム:Python3.10
-
アーキテクチャ:arm64
-
実行ロール:基本的な Lambda アクセス権限で新しいロールを作成
その他の設定項目についてはデフォルトのままにしています。
画面右下の「関数の作成」ボタンをクリックすると Lambda 関数が作成されます。
Lambda 関数コードの作成
今回、検証用に以下のコードを用意しました。
import json import os import boto3 import base64 import gzip # SNSトピックARN:環境変数から取得 sns_topic_arn = os.environ['SNS_TOPIC_ARN'] # SNSクライアントを作成 sns_client = boto3.client('sns') def lambda_handler(event, context): try: # base64エンコードされたデータをデコード decoded_data = base64.b64decode(event['awslogs']['data']) # gzip圧縮されたログデータを展開 log_data = json.loads(gzip.decompress(decoded_data)) # SNSメッセージ件名 sns_subject = "クライアントVPN接続通知" # SNSメッセージ本文 sns_message = "Client VPN エンドポイントへの接続を検知しました。\n\n---\n" # ログ内容を展開してSNSメッセージ本文に追加 log_length = len(log_data['logEvents']) for i in range(log_length): log_content = json.loads(log_data['logEvents'][i]['message']) for key, value in log_content.items(): sns_message += f"- {key}: {value}\n" sns_message += "---\n" # SNSメッセージを発行 response = sns_client.publish( TopicArn=sns_topic_arn, Message=sns_message, Subject=sns_subject, ) return response except Exception as e: print(e) raise e
このコードを Lambda コンソール上で入力(コピペ)して「Deploy」ボタンをクリックしてコードを適用します。
ちなみに、CloudWatch Logs サブスクリプションフィルターから Lambda 関数に渡されるデータは以下のとおりです。
{ "awslogs": { "data": "BASE64ENCODED_GZIP_COMPRESSED_DATA" } }
このデータのうち"BASE64ENCODED_GZIP_COMPRESSED_DATA"
の部分にログデータを gzip 形式で圧縮したうえに base64 エンコードしたものが格納されています。
したがって、"BASE64ENCODED_GZIP_COMPRESSED_DATA"
のデータを base64 デコードすると圧縮データが取り出せるので、この圧縮データをさらに展開して取り出すと今度は以下の構造のデータを取得できます。
{ "owner": "123456789012", "logGroup": "CloudTrail", "logStream": "123456789012_CloudTrail_us-east-1", "subscriptionFilters": [ "Destination" ], "messageType": "DATA_MESSAGE", "logEvents": [ { "id": "31953106606966983378809025079804211143289615424298221568", "timestamp": 1432826855000, "message": "{\"eventVersion\":\"1.03\",\"userIdentity\":{\"type\":\"Root\"}" }, { "id": "31953106606966983378809025079804211143289615424298221569", "timestamp": 1432826855000, "message": "{\"eventVersion\":\"1.03\",\"userIdentity\":{\"type\":\"Root\"}" }, { "id": "31953106606966983378809025079804211143289615424298221570", "timestamp": 1432826855000, "message": "{\"eventVersion\":\"1.03\",\"userIdentity\":{\"type\":\"Root\"}" } ] }
そして、上記データの「message」キーの値として格納されている文字列が CloudWatch に保存されるログの内容です。
上記は 下記の AWS 公式ドキュメントから引用したものなので、「message」キーの値は、実際の AWS Client VPN の接続ログの内容とは異なっています。
まるでマトリョーシカのような入れ子構造を持つデータで Lambda 関数のコード作成時にとても困惑したので、ここで整理させていただきました。
話が逸れてしまいましたが、Lambda 関数コードの適用は完了です。
ただし、この関数コードを実行するためには「実行ロールの権限追加」と「環境変数の設定」が追加で必要となります。
Lambda 実行ロールの権限追加
Lambda コンソールの「設定」タブで「アクセス権限」を選び、表示されたロール名のリンクをクリックします。
IAM ロールの詳細画面が開くので、「許可を追加」をクリックしてプルダウンから「ポリシーをアタッチ」を選びます。
検索ボックスにsns
と入力してEnterキーを押すと、AWSIoTDeviceDefenderPublishFindingsToSNSMitigationActionという長い名称の AWS 管理ポリシーが検索結果に出てくるのでこれにチェックを入れて「許可を追加」ボタンをクリックします。
これで Lambda 実行ロールの権限追加は完了です。
ちなみに追加した IAM ポリシーの内容は以下のとおり Amazon SNS にメッセージを発行するための許可となります。
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "sns:Publish" ], "Resource": [ "*" ] } ] }
Lambda 環境変数の設定
Lambda コンソールに戻って「設定」タブの「環境変数」を選択します。
「編集」ボタンをクリックすると環境変数の編集画面になります。
「環境変数の追加」ボタンをクリックして、キーにSNS_TOPIC_ARN
、値には通知用に使う Amazon SNS トピックのARN を入力して「保存」ボタンを押します。
CloudWatch Logs サブスクリプションフィルターの作成
Lambda 関数が用意できたので、CloudWatch Logs サブスクリプションフィルターを作成します。
CloudWatch コンソールを開き、サイドメニューから「ロググループ」を選択します。
一覧から、検知対象の Client VPN エンドポイントで接続ログの保存先としているロググループをのチェックボックスをオンにします。
「アクション」を選択し、プルダウンから「サブスクリプションフィルター」 > 「Labmda サブスクリプションフィルターを作成」を選択します。
サブスクリプションフィルターの作成画面に遷移するので、各設定項目を下画像のように入力します。
Lambda 関数には先ほど作成したものを指定します。
今回、ログの絞り込みを行いたいのでサブスクリプションフィルターのパターンに、
{ $.connection-attempt-status = "successful" }
と入力します。
こうすることで、CloudWatch Logs に保存される接続ログのうち「connection-attempt-status」の項目がsuccessful
の値のログ、つまり、VPN エンドポイントへの接続開始時のログだけを抽出して Lambda 関数に配信することができます。
本記事ではフィルターパターン構文の詳細に関する解説は割愛させていただきますが、以下のAWS公式ドキュメントに解説や記載例が掲載されていますので、必要に応じてこちらをご参照ください。
サブスクリプションフィルター名には任意の名称を入力します。
画面右下の「ストリーミングを開始」ボタンをクリックすると、Lambda 関数へのログ配信が開始されます。
動作確認
実際に接続の検知・通知が行われるかを検証するために、検知対象の VPN エンドポイントに接続を行いました。
しかし、10分以上が経過しても通知が届きません。
CloudWatch コンソールで接続ログの保存先に指定しているロググループを確認してみたところ、AWS Client VPN からのログがまだ記録されていませんでした。
過去の接続ログを遡って確認してみると、AWS Client VPN の接続ログはある程度の間隔を空けて CloudWatch にまとめて配信されているようです。
上画像ではログのタイムスタンプごとに色分けしていますが、時間を空けて複数ログが一斉に記録されているのが分かります。
ログの配信間隔も正確に定まっていないようで、私の環境で確認した限り、短いもので10分程度、長いもので40分程度の開きがありました。
この辺りは公式ドキュメントに明記されたものを見つけられていませんが、いずれにしても接続ログをもとに接続検知しようとするとリアルタイム性に欠けると言えます。
その後、VPN エンドポイントへの接続開始から30分ほど経過したタイミングで通知メールが届きました。
先ほど接続ログを確認したとおり、複数のログがまとめて通知されています。
ただ、通知内容にログ情報が含まれているので、わざわざマネジメントコンソールを開かずとも接続ログの内容を確認することができました。
さいごに
今回は、AWS Client VPN の接続検知を行う方法として CloudWatch Logs サブスクリプションフィルターを利用する方法を試してみました。
Lambda 関数に渡されるデータの構造が少々複雑だったのでコード作成に手間取りましたが、通知内容から接続ログの情報を確認できるのは大きな利点だと思います。
また、前回記事で紹介した CloudWatch メトリクスを使った接続検知方法と比べ、障害などの影響を受けない限り検知漏れの可能性はほぼないと言えます。
ただし、この方法では接続検知のリアルタイム性に欠けるという欠点もあります。
ケースバイケースになるとは思いますが、接続検知にどこまでリアルタイム性を求めるかによって、この方法の有用性が分かれるところだと考えます。
この記事が何かの参考となれば幸いです。