Zenn
🗒️

TerraformでSAMLによるSSO認証が可能なAWS Client VPNを構築する

2025/01/08に公開

何気にプラットフォームに記事を書くのは初めてになります。
不束者ですが、よろしくお願いいたします。

今回は諸々の条件を満たしたVPNを構築するため、TerraformでAWS Client VPNを構築する必要性に駆られた筆者ことニシキが試行錯誤をした学習内容を記録として残しました。
同じことをしようとして困っている誰かの役に立てれば幸いです。

概要

AWSを利用するという前提のもと、Terraformで以下の条件を満たすVPNを構築する必要がありました。

  • 固定IPが付与されている
  • セキュリティパッチの適用などの運用の手間をできる限り減らしたい
  • SAML連携を利用してSSOを行いたい
  • VPN経由でインターネットに接続したい
    • イントラのシステムと通信するためのVPNではない
  • 必要な時だけ構築を行い利用が終われば破棄したい。

これらを満たしたVPNを作成するために、以下の手段を用いることができそうだという結論になりました。

  • AWS Client VPNを採用する
  • IaC化(Terraform)することで必要な時だけ固定IPのVPNを作成できるようにする。
    • その場合のアカウント管理はIAM Identity Center(SAML連携)でアカウントを発行する
    • AWSコンソールでいつでもアカウントの無効化が可能

この記事ではこれらを実現するために記述したソースコードや、その際に詰まったところなどを書いていこうと考えています。

成果物

以下が今回の成果物になります。(動作確認済み)
記事中に記載しているソースコードは単体では動かない場合がありますので、
全体のソースコードにつきましてはこちらを参照していただけるとありがたいです。

https://github.com/s-hys31/AWS-Client-VPN-with-Terraform

参考

構築にあたっては以下の記事を参考にさせていただきました。
おかげ様で無事構築することができました。ありがとうございました。

https://zenn.dev/aidemy/articles/21f4a82c3e017b
https://qiita.com/sakai00kou/items/4fe05331d5a04bcca772
https://dev.classmethod.jp/articles/aws-client-vpn-customdnsserver

構築内容

構成

VPC + Subnet + NAT Gateway

まずはVPNを設置するVPCを構築します。

VPNでDNSの名前解決を可能にするために、enable_dns_supporttrueにしておく必要があります。
この設定はデフォルトでtrueになっているので記述しなくても問題ないようです。

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true

  tags = {
    Name = "${var.prefix} VPN VPC"
  }
}

続いてサブネットとNAT Gatewayを作成します。
プライベートサブネットからのインターネットへの通信をNAT Gatewayにルーティングすることで、プライベートサブネットからインターネットへの全ての通信を同じ固定グローバルIPで行うことができます。
(別の話ですが、Lambdaに対して固定IPを付与するときなどにも同じ方法を使いました。)

resource "aws_subnet" "private" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"

  tags = {
    Name = "${var.prefix} VPN Private Subnet"
  }
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.2.0/24"

  tags = {
    Name = "${var.prefix} VPN Public Subnet"
  }
}

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.prefix} VPN IGW"
  }
}

resource "aws_route_table" "vpc_to_internet" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }

  tags = {
    Name = "${var.prefix} VPN Public Route Table"
  }
}

resource "aws_route_table_association" "public_to_internet" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.vpc_to_internet.id
}

resource "aws_nat_gateway" "nat" {
  allocation_id = var.aws_eip_nat_id
  subnet_id     = aws_subnet.public.id

  tags = {
    Name = "${var.prefix} VPN NAT Gateway"
  }
}

resource "aws_route_table" "private_to_nat" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat.id
  }

  tags = {
    Name = "${var.prefix} VPN Private Route Table"
  }
}

resource "aws_route_table_association" "private_to_nat" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private_to_nat.id
}

SAML Provider

最初こちらについては、以下の理由で取り組んでいませんでした。

  • 過去に一つのAWSアカウントに対してTerraformで複数の環境を構築する際に、GitHubのOIDC連携において環境ごとにIAM Identity Providerを作ろうとすると同じものは作れないというエラーが発生していた[1]
    • SAMLも下手に作ると環境を分ける必要がある際に問題になってしまう?という懸念

ですが、SAMLはアプリケーション毎にIdPに登録しメタデータを発行するため、そのようなことは起きないと解釈しこちらのIaC化にも取り組んでみました。

今回はVPNクライアントとセルフサービスポータルの二つのアプリケーションにSAMLを利用するため、それぞれにIAM Saml Providerを作成します。

resource "aws_iam_saml_provider" "main" {
  name                   = "${random_id.main.hex}-saml-provider"
  saml_metadata_document = file("${path.root}/saml-metadata.xml")

  tags = {
    Name = "${var.prefix} SAML Provider"
  }
}

resource "aws_iam_saml_provider" "self_service_portal" {
  name                   = "${random_id.main.hex}-self-service-portal-saml-provider"
  saml_metadata_document = file("${path.root}/self-service-portal-saml-metadata.xml")

  tags = {
    Name = "${var.prefix} Self Service Portal SAML Provider"
  }
}

今回はSAML IdPとしてIAM Identity Centerを利用します。
参考にした以下の記事に従って、カスタムSAML2.0アプリケーションの追加からVPNクライアントとセルフサービスポータルを追加します。

https://qiita.com/sakai00kou/items/4fe05331d5a04bcca772#iam-identity-center関連の設定

IAM Identity Center SAMLメタデータファイルもダウンロードしておきます。セルフサービスポータルのほうのファイル名はself-service-portal-saml-metadata.xmlに変更しておきました。

Terraform環境のルートディレクトリにて、以下のようにファイルを設置することでSAMLメタデータファイルが参照されリソースが作成されます。

.
…(省略)
├ main.tf
├ saml-metadata.xml
└ self-service-portal-saml-metadata.xml

Client VPN

Client VPN Endpointに適用するセキュリティグループを作成します。

こちらに関しては頭を振り絞って考えてみてたのですが、なぜプライベートサブネットのCIDRを許可してるのかはっきりとは理解していません。Client VPN Endpointのネットワーク周りの理解が進めばわかると思うので、今はそれが必要であるというところで思考を止めています。

resource "aws_security_group" "vpn" {
  vpc_id = aws_vpc.main.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = [aws_subnet.private.cidr_block]
  }
}

次にClient VPN Endpointを作成します。
今回は前述のとおりSAMLプロバイダーを用いて認証を行うようにしました。
authentication_optionssaml_*に、IAM Identity Providerに登録した各種アプリケーションのARNを登録することで連携が可能です。

dns_servers

構築中で躓いたこととして、dns_serversの設定があります。
こちらの指定がない場合、基本的にはクライアントPCに指定されているプロバイダーDNSが利用されるようなのですが、環境によってドメイン名の解決ができないことがあるようです。(プロバイダー側でブロックされている?)
今回は回避策として外部のDNSサーバー(1.1.1.1)を指定しました。これでVPNからの名前解決が行えるようになりました。

また、接続のログをCloudWatch Logsに記録するようにしています。

resource "aws_ec2_client_vpn_endpoint" "main" {
  server_certificate_arn = var.server_certificate_arn
  client_cidr_block      = "10.10.0.0/22"
  vpc_id                 = var.vpc_id
  split_tunnel           = false
  session_timeout_hours  = 24
  vpn_port               = 443
  self_service_portal    = "enabled"
  dns_servers            = var.dns_servers

  authentication_options {
    type                           = "federated-authentication"
    saml_provider_arn              = aws_iam_saml_provider.main.arn
    self_service_saml_provider_arn = aws_iam_saml_provider.self_service_portal.arn
  }

  connection_log_options {
    enabled              = true
    cloudwatch_log_group = aws_cloudwatch_log_group.vpn.name
  }

  security_group_ids = [var.security_group_id]

  tags = {
    Name = "${var.prefix} VPN Endpoint"
  }
}

resource "aws_cloudwatch_log_group" "vpn" {
  name              = "/aws/ec2/client-vpn-connections-${random_id.main.hex}"
  retention_in_days = 90
}

resource "aws_ec2_client_vpn_network_association" "vpn_to_private" {
  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id
  subnet_id              = var.subnet_id
}

resource "aws_ec2_client_vpn_authorization_rule" "vpn_to_private" {
  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id
  target_network_cidr    = "0.0.0.0/0"
  authorize_all_groups   = true
}

resource "aws_ec2_client_vpn_route" "vpn_to_private" {
  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id
  destination_cidr_block = "0.0.0.0/0"
  target_vpc_subnet_id   = var.subnet_id

  depends_on = [aws_ec2_client_vpn_network_association.vpn_to_private]
}

感想・まとめ

というわけで今回はTerraformでAWS Client VPNを構築してみました。

VPNの採用における要件は色々あると思いますが、割と自由に組み合わせをすることができたり、必要な時にさっと用意することができるので、AWS Client VPNとTerraformの組み合わせは結構良いなと感じてます。

初めてまともに人に読んでいただくための記事を書いたのですが、参考記事そのままにならないようにするのが中々どうして難しかったです。
少しずつでも良い記事を書けるよう、これから技術・文章の研鑽を積んでいきたいと思います。

今回はご覧いただきありがとうございました。次回があればその時はまたよろしくお願いいたします。

脚注
  1. GitHubのOIDC連携に関しては、複数環境から同時に参照されるリソースを管理するまとまりを別途作ることで解決しています。 ↩︎

Discussion

ログインするとコメントできます