AWS Client VPN でクライアントの接続元パブリック IP アドレスに応じた 許可 IP リスト/拒否 IP リストの仕組みを考えてみた

特定の IP アドレスからのクライアント VPN エンドポイントへの接続は拒否したい、事前に許可した IP アドレスからのみ許可したい、といったケースに。

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

コンバンハ、千葉(幸)です。

AWS Client VPN では、接続元のクライアント属性に応じた接続可否のコントロールができます。属性の一つとして接続元のパブリック IP アドレスがあるため、特定の IP アドレスに対して許可/拒否を行うといったこともできます。

これは先日のアップデートで可能となりました。クライアント接続ハンドラと呼ばれる機能です。

カスタマー側で Lambda 関数を定義し、そのロジックにより接続可否が決定されます。上記のエントリでは単一の IP アドレスをコードに直接埋め込む、という形式で検証しました。

実際の運用を考えると、IPはリストで持ちたいですし、コードの外部で管理を行いたいです。今回はそれを実現するために、二つのパターンを考えてみました。

先にまとめ

  • 本エントリでは以下のいずれかに IP リストを持たせます
    • Lambda 関数の環境変数
    • Systems Manager パラメータストア
  • コードはあくまで検証用のものです
  • 以下エントリを参考にしました。ありがとう 市田 さん。

下準備:Lambda 関数の作成

ベースとなる Lambda 関数を作成しておきます。

とは言っても、特別なことはしません。関数名がAWSClientVPN-から始まる必要がある、といった点以外は特別な要件はありません。

マネジメントコンソールで「一から作成」を選択し、以下を指定し作成を行います。

  • 関数名:AWSClientVPN-Test
  • ランタイム:Python 3.8
  • IAM ロール:自動作成に任せる

なお、クライアント接続ハンドラにおいては、 Lambda 関数は 同期的 に呼び出されることに注意してください。

また、他の考慮事項を含めた詳細は以下を確認してください。

参考:前回のコード

前回のエントリで載せたコードを再掲します。

特定の IP アドレスからの接続はブロックする、というものです。

def lambda_handler(event, context):
    
    ip = event['public-ip']
    
    if ip == "202.xx.xx.xx":
        flag = 0
    else:
        flag = 1
    
    return {
            "allow": bool(flag),
            "error-msg-on-failed-posture-compliance": "Your IP is not allowed to access. Please Contact mail@example.co.jp",
            "posture-compliance-statuses": ["compliant"],
            "schema-version": "v1"
            }

今回は、このコードに最低限の手を加えて IP リストに対応させていきます。(手を加えた後もあくまで検証用のコードであることはご了承ください。)

Client VPN により Lambda 関数が呼び出される際には、以下のインプットがあります。ここから public-ipを参照し、接続可否を判断するというシンプルなロジックを組んでいます。

{
    "connection-id": <connection ID>,
    "endpoint-id": <client VPN endpoint ID>,
    "common-name": <cert-common-name>,
    "username": <user identifier>,
    "platform": <OS platform>,
    "platform-version": <OS version>,
    "public-ip": <public IP address>,
    "client-openvpn-version": <client OpenVPN version>,
    "schema-version": "v1"
}

環境変数に IP リストを入力するパターン

まずはお手軽に環境変数に IP リストを持たせるパターンを試してみます。

IP リストは以下のようにカンマ区切りで羅列させる前提とします。

xx.xx.xx.xx,yy.yy.yy.yy,zz.zz.zz.zz

環境変数に IP リストを登録する

環境変数を設定します。今回はマネジメントコンソールから登録します。キーをIP_LISTとして値の登録を行いました。

環境変数の合計サイズは 4KB を超えてはならない、という制限がありますが、今回のケースでは特に問題となるものではないでしょう。

Lambda 環境変数のカンマ区切りの文字列を配列として扱う

環境変数に指定した値は、文字列として扱われることになります。これを配列として扱うために、処理を加えます。

以下エントリを参考にしました。

手を加えたコードは以下です。

import os

ip_list = os.getenv("IP_LIST").split(',')

def lambda_handler(event, context):

    client_ip = event['public-ip']
    flag = client_ip not in ip_list

    return {
            "allow": flag,
            "error-msg-on-failed-posture-compliance": "Access from this source IP address is not allowed",
            "posture-compliance-statuses": [],
            "schema-version": "v1"
            }

冒頭で IP リストを取得し、接続元の IP アドレスがそれに合致するものであれば 拒否する という挙動となります(拒否 IP リスト)。判定を行う 8 行目のnotを取り除けば許可 IP リストとして機能します。

上記の Lambda 関数を Client VPN エンドポイントのクライアント接続ハンドラとして設定しておきます。

動作確認(拒否 IP リストによるブロック)

拒否 IP リストに含まれるパブリック IP アドレスから接続を試みます。なお、クライアントは AWS VPN Client for macOS 1.2.4 です。

クライアントのパブリック IP は以下です。(リストに含まれるもの。)

% curl http://checkip.amazonaws.com/
202.xx.xx.xx

想定どおり接続が拒否されました。

クライアントには Lambda 関数で指定したメッセージが表示されます。

動作確認(拒否 IP リストの対象外)

拒否 IP リストに含まれていないパブリック IP アドレスから接続を試みます。

% curl https:///checkip.amazonaws.com/
1.75.xx.xx

問題なく接続が成功しました。

IP リストを環境変数に持たせた場合の動作確認ができました。

SSM パラメータストアに IP リストを入力するパターン

環境変数でリストを管理するというのも悪くないですが、場合によってはさらに別出しにして管理したいという場面があるかもしれません。

ということで、 Systems Manager のパラメータストアを利用してみます。

ここを使えば Lambda のコンソールで作業する必要もありませんし、バージョン管理も手軽に行えます。複数の Lambda 関数から同一のリストを参照させる、ということもできますね。

SSM パラメータストアへ IP リストを登録する

早速登録していきます。

なお、先ほどと同じようにカンマ区切りでリストを持たせることも可能なのですが、折角なので趣を変えて json 形式で登録することにします。

そうすることで、以下のように IP アドレスに関する説明を付与できるようになります。

{
    "xx.xx.xx.xx": "chiba-office",
    "yy.yy.yy.yy": "saitama-office",
    "zz.zz.zz.zz": "kanagawa-office"
}

今回はIP_LISTという名称で登録を行いました。

値を変更した際には、このように履歴を確認することができます。

Lambda 関数のロールにポリシーを追加する

上記で登録したパラメータストアの値を、クライアント接続ハンドラとして呼び出された Lambda 関数が参照できる必要があります。

Lambda 関数作成時にデフォルトで作成された IAM ロール にはそのような権限は与えられていませんので、ポリシーを作成し、アタッチします。

以下のようなポリシーを作成し、Lambda 関数用ロールにアタッチしました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameter"
            ],
            "Resource": "*"
        }
    ]
}

冒頭に記載した参考ブログでは "ssm:GetParameters" を使用しているので、そこの差異には注意してください。

json 形式の値を辞書型として取り込む

SSM パラメータストアに登録した json 形式の値を、辞書型として取り込むよう改修したコードが以下です。接続元の IP アドレスをリストと突き合わせて、合致すれば拒否する、という挙動は変わりません。

import json
import boto3

ssm = boto3.client('ssm')
ssm_response = ssm.get_parameter(
    Name = 'IP_LIST'
    )
    
ip_list = ssm_response['Parameter']['Value']
mydict = json.loads(ip_list)

def lambda_handler(event, context):
    
    client_ip = event['public-ip']
    flag = client_ip not in mydict

    return {
            "allow": flag,
            "error-msg-on-failed-posture-compliance": "Please Go Home.",
            "posture-compliance-statuses": [],
            "schema-version": "v1"
            }

動作確認(拒否 IP リストによるブロック)

再び動作確認です。上記のコードをクライアント接続ハンドラの Lambda 関数にデプロイし、接続を試みます。

拒否 IP リストに含まれるパブリック IP アドレスから接続を試みます。

% curl http://checkip.amazonaws.com/
202.xx.xx.xx

想定どおり接続が拒否されました。

動作確認(拒否 IP リストの対象外)

拒否 IP リストに含まれていないパブリック IP アドレスから接続を試みます。

% curl https:///checkip.amazonaws.com/
1.75.xx.xx

問題なく接続が成功しました。

(これだけ同じようなことをするなら、わざわざ実際に行うのではなく、 Lambda コンソールの [テスト] で確認するだけの方が幸せだったかもしれません。。)

パラメータストアを使用するパターンも動作確認ができました。

終わりに

AWS Client VPN における許可 IP / 拒否 IP リストの仕組みを試してみました。

手っ取り早いのは環境変数を使用するパターンですが、履歴など含めて管理したい、という場合には SSM パラメータストアをご利用ください。

今回のコードは検証用のごくシンプルなものですが、部分々々で参考になれば幸いです。

クライアント接続ハンドラで、やりたいことに応じていかようにもできる、というのは面白いですね。

以上、千葉(幸)がお送りしました。