CTO室のkazです。2022年最初の投稿は「WeWork東京スクエアガーデンへの本社機能移転決議」の裏で業務に支障が出ないよう、仕組みを考え、検証して構築する。そしてそれをみんなにつかってもらうタスクの1つであるVPNを紹介します。
状況
現オフィスでは某ベンダーのネットワークアプライアンスを導入して運用しています。 4年半前に20名ほどが準備期間1ヶ月ほどで移転してきたのですが、将来的な採用計画が不透明な状態の中、まぁ、ざっくり100名程度の処理をさばける性能であればいいかなというアバウトな要件で構築したのですが、幸い100名を超える常時接続となった現在でもH/Wリソースには余裕があり、安定的に運用できています。(オンプレで予め確保していた導入時では過剰と思われたリソースも、短期間のうちに成長していくスタートアップのケースでは刺さる)
目的
今回の移転では全従業員がテレワーク中心の働き方へとシフトしたので現在よりも機密性と可用性を向上させるとともに社内NW担当者(自分自身)の高止まりしている運用負荷を下げることがゴールです。
要件
要件としては大きく2点。
- 300名程度(現在のおよそ3倍)が同時接続し遅延なく使用できること
- マルチプラットフォームであること
方法
AWS ClientVPN と Azure Active Directory (Azure AD) を統合させます。 フルマネージドサービスだと不具合や障害調査など権限の問題もあり、解決や状況把握に時間を要するケースがあった為、今回はAWS Client VPNを構築し、すべて管理下に置くことにしました。
構成
IdP
Microsoft Azure
- AWS ClientVPNへの接続ユーザをAzureADで制御
- Azure ADアカウントを用いてAWS ClientVPN に自動的にサインインさせる
SP
AWS Client VPN
- セルフサービスポータル URLを作成することで運用コストを下げる
- 認証はfederated-authenticationを選択
- VPNを作成するVPCはDNS解決、DNSホストを有効にしておく
構築
Azure
Azure Active Directory シングル サインオン (SSO) と AWS ClientVPN の統合を参照する
Azure Active Directory > Enterprise applications > New application
aws clientvpnで検索しアプリケーションを選択し作成する
ユーザを登録
Set up single sign on > SAML
パラメータ入力
App registrations > AWS ClientVPN
Manifest > > 応答URLをhttpに更新
Federation Metadata XMLをダウンロードする
AWS
ダウンロードしたFederation Metadata XMLでSAML用のIAM Identity Providerを作成する
- IAM設定ファイル
vpn-config ├── common │ ├── iam │ │ ├── azure.xml │ │ ├── iam_saml_provider.tf │ │ └── main.tf │ └── server-crt │ ├── README.md (snip)
aws_iam_saml_provider.tf
resource "aws_iam_saml_provider" "enechange_azure" { name = "azure" saml_metadata_document = file("azure.xml") }
- AWS ClientVPNで使用するサーバ証明書を作成
- OpenVPN easy-rsa リポジトリのクローンをローカルコンピュータに作成して、easy-rsa/easyrsa3 フォルダに移動させる
% git clone https://github.com/OpenVPN/easy-rsa.git % cd easy-rsa/easyrsa3 % ./easyrsa init-pki % ./easyrsa build-ca nopass % ./easyrsa build-server-full server nopass
- サーバー証明書とキーをカスタムフォルダにコピーしてから、カスタムフォルダに移動させる
% mkdir ~/custom_folder/ % cp pki/ca.crt ~/custom_folder/ % cp pki/issued/server.crt ~/custom_folder/ % cp pki/private/server.key ~/custom_folder/ % cp pki/issued/client1.domain.tld.crt ~/custom_folder % cp pki/private/client1.domain.tld.key ~/custom_folder/ % cd ~/custom_folder/
- サーバー証明書とキーを ACM にアップロードする
% aws acm import-certificate --certificate fileb://server.crt --private-key fileb://server.key --certificate-chain fileb://ca.crt
VPN設定ファイル
vpn-config (snip) │ └── network ├── cloudwatch_log_group.tf ├── cloudwatch_metric_alarm.tf ├── ec2_client_vpn_endpoint.tf ├── eip.tf ├── internet_gateway.tf ├── main.tf ├── nat_gateway.tf ├── route.tf ├── route_table.tf ├── route_table_association.tf ├── security_group.tf ├── subnet.tf ├── variables.tf └── vpc.tf
cloudwatch_log_group.tf
resource "aws_cloudwatch_log_group" "client_vpn" { name = "client-vpn" retention_in_days = 365 tags = { Name = "client-vpn" Service = "client-vpn" Description = "Managed by Terraform" } } resource "aws_cloudwatch_log_stream" "client_vpn" { name = "client-vpn-logstream" log_group_name = aws_cloudwatch_log_group.client_vpn.name }
cloudwatch_metric_alarm.tf
resource "aws_cloudwatch_metric_alarm" "client_vpn" { for_each = var.client_vpn_cloudwatch_metrics alarm_name = each.value.alarm_name comparison_operator = each.value.comparison_operator evaluation_periods = "2" treat_missing_data = "notBreaching" metric_name = each.value.metric_name namespace = "AWS/ClientVPN" period = "60" statistic = each.value.statistic threshold = each.value.threshold alarm_actions = ["arn:aws:sns:ap-northeast-1:account-id:alert-client-vpn"] ok_actions = ["arn:aws:sns:ap-northeast-1:account-id:alert-client-vpn"] dimensions = { Endpoint = "cvpn-endpoint-*********" } }
ec2_client_vpn_endpoint.tf
resource "aws_ec2_client_vpn_endpoint" "enechange" { client_cidr_block = "192.168.0.0/20" description = "use tcp" dns_servers = [ "1.1.1.1", "8.8.8.8", ] self_service_portal = "enabled" server_certificate_arn = data.aws_acm_certificate.issued.arn split_tunnel = false tags = { Name = "enechange-vpn-federated-authentication" Service = "client-vpn" Description = "Managed by Terraform" } transport_protocol = "tcp" authentication_options { saml_provider_arn = "arn:aws:iam::account-id:saml-provider/azure" self_service_saml_provider_arn = "arn:aws:iam::account-id:saml-provider/azure" type = "federated-authentication" } connection_log_options { enabled = true cloudwatch_log_group = aws_cloudwatch_log_group.client_vpn.name cloudwatch_log_stream = aws_cloudwatch_log_stream.client_vpn.name } } resource "aws_ec2_client_vpn_network_association" "enechange_assoc" { for_each = var.vpn_client_nat_route_assoc client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.enechange.id subnet_id = aws_subnet.client_vpn[each.key].id security_groups = [aws_security_group.client_vpn.id] } resource "aws_ec2_client_vpn_authorization_rule" "enechange_authorization" { client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.enechange.id target_network_cidr = "0.0.0.0/0" authorize_all_groups = true } resource "aws_ec2_client_vpn_route" "enechange_route_1a" { client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.enechange.id destination_cidr_block = "0.0.0.0/0" target_vpc_subnet_id = aws_subnet.client_vpn["private-1a"].id } resource "aws_ec2_client_vpn_route" "enechange_route_1c" { client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.enechange.id destination_cidr_block = "0.0.0.0/0" target_vpc_subnet_id = aws_subnet.client_vpn["private-1c"].id }
eip.tf
resource "aws_eip" "client_vpn" { for_each = var.vpn_client_natgw vpc = true tags = { Name = each.value.name Service = "client-vpn" Description = "Managed by Terraform" } }
internet_gateway.tf
resource "aws_internet_gateway" "client_vpn" { vpc_id = aws_vpc.client_vpn.id tags = { Name = "client-vpn" Service = "client-vpn" Description = "Managed by Terraform" } }
nat_gateway.tf
resource "aws_nat_gateway" "client_vpn" { for_each = var.vpn_client_natgw allocation_id = aws_eip.client_vpn[each.key].id subnet_id = aws_subnet.client_vpn[each.key].id tags = { Name = each.value.name Service = "client-vpn" Description = "Managed by Terraform" } }
route.tf
locals { public-names = [ "public-1a", "public-1c" ] } resource "aws_route" "client_vpn_public" { for_each = toset(local.public-names) route_table_id = aws_route_table.client_vpn[each.key].id gateway_id = aws_internet_gateway.client_vpn.id destination_cidr_block = "0.0.0.0/0" } resource "aws_route" "client_vpn_private" { for_each = var.vpn_client_nat_route_assoc route_table_id = aws_route_table.client_vpn[each.key].id nat_gateway_id = aws_nat_gateway.client_vpn[each.value.natgw-name].id destination_cidr_block = "0.0.0.0/0" }
route_table.tf
resource "aws_route_table" "client_vpn" { for_each = var.client_vpn_subnets vpc_id = aws_vpc.client_vpn.id tags = { Name = each.value.name Service = "client-vpn" Description = "Managed by Terraform" } }
route_table_association.tf
resource "aws_route_table_association" "client_vpn" { for_each = var.client_vpn_subnets subnet_id = aws_subnet.client_vpn[each.key].id route_table_id = aws_route_table.client_vpn[each.key].id }
security_group.tf
resource "aws_security_group" "client_vpn" { name = "client-vpn" vpc_id = aws_vpc.client_vpn.id egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "client-vpn" Service = "client-vpn" Description = "Managed by Terraform" } } resource "aws_security_group_rule" "client_vpn_80" { type = "ingress" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.client_vpn.id } resource "aws_security_group_rule" "client_vpn_443" { type = "ingress" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.client_vpn.id }
subnet.tf
resource "aws_subnet" "client_vpn" { for_each = var.client_vpn_subnets vpc_id = aws_vpc.client_vpn.id availability_zone = each.value.zone cidr_block = each.value.cidr map_public_ip_on_launch = each.value.launch tags = { Name = each.value.name Service = "client-vpn" Description = "Managed by Terraform" } }
vpc.tf
resource "aws_vpc" "client_vpn" { cidr_block = "10.0.0.0/16" instance_tenancy = "default" tags = { Name = "client-vpn" Service = "client-vpn" Description = "Managed by Terraform" } }
variables.tf
variable "vpn_client_nat_route_assoc" { type = map(any) default = { private-1a = { natgw-name = "public-1a" } private-1c = { natgw-name = "public-1c" } } } variable "vpn_client_natgw" { type = map(any) default = { public-1a = { name = "client-vpn-public-1a" } public-1c = { name = "client-vpn-public-1c" } } } variable "client_vpn_subnets" { type = map(any) default = { public-1a = { cidr = "10.0.0.0/24" zone = "ap-northeast-1a" launch = "true" name = "client-vpn-public-1a" } public-1c = { cidr = "10.0.1.0/24" zone = "ap-northeast-1c" launch = "true" name = "client-vpn-public-1c" } private-1a = { cidr = "10.0.2.0/24" zone = "ap-northeast-1a" launch = "false" name = "client-vpn-private-1a" } private-1c = { cidr = "10.0.3.0/24" zone = "ap-northeast-1c" launch = "false" name = "client-vpn-private-1c" } } } variable "client_vpn_cloudwatch_metrics" { type = map(any) default = { ActiveConnectionsCount = { alarm_name = "[CVPN]cvpn-endpoint-******************-ActiveConnectionsCount" metric_name = "ActiveConnectionsCount" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントへのアクティブな接続数" } AuthenticationFailures = { alarm_name = "[CVPN]cvpn-endpoint-******************-AuthenticationFailures" metric_name = "AuthenticationFailures" statistic = "Sum" threshold = "10" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントの認証失敗数" } CrlDaysToExpiry = { alarm_name = "[CVPN]cvpn-endpoint-******************-CrlDaysToExpiry" metric_name = "CrlDaysToExpiry" statistic = "Sum" threshold = "30" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントで設定されている証明書失効リスト (CRL) の有効期限が切れるまでの日数" } SelfServicePortalClientConfigurationDownloads = { alarm_name = "[CVPN]cvpn-endpoint-******************-SelfServicePortalClientConfigurationDownloads" metric_name = "SelfServicePortalClientConfigurationDownloads" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "セルフサービスポータルからの Client VPN エンドポイント設定ファイルのダウンロード数" } ClientConnectHandlerTimeouts = { alarm_name = "[CVPN]cvpn-endpoint-******************-ClientConnectHandlerTimeouts" metric_name = "ClientConnectHandlerTimeouts" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントへの接続用のクライアント接続ハンドラの呼び出し時のタイムアウト数" } ClientConnectHandlerInvalidResponses = { alarm_name = "[CVPN]cvpn-endpoint-******************-ClientConnectHandlerInvalidResponses" metric_name = "ClientConnectHandlerInvalidResponses" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントへの接続用のクライアント接続ハンドラから返された無効な応答の数" } ClientConnectHandlerOtherExecutionErrors = { alarm_name = "[CVPN]cvpn-endpoint-******************-ClientConnectHandlerOtherExecutionErrors" metric_name = "ClientConnectHandlerOtherExecutionErrors" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントへの接続用のクライアント接続ハンドラの実行中に発生した、予期されていなかったエラーの数" } ClientConnectHandlerThrottlingErrors = { alarm_name = "[CVPN]cvpn-endpoint-******************-ClientConnectHandlerThrottlingErrors" metric_name = "ClientConnectHandlerThrottlingErrors" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントへの接続用のクライアント接続ハンドラの呼び出し時のスロットリングエラーの数" } ClientConnectHandlerDeniedConnections = { alarm_name = "[CVPN]cvpn-endpoint-******************-ClientConnectHandlerDeniedConnections" metric_name = "ClientConnectHandlerDeniedConnections" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントへの接続用のクライアント接続ハンドラによって拒否された接続の数" } ClientConnectHandlerFailedServiceErrors = { alarm_name = "[CVPN]cvpn-endpoint-******************-ClientConnectHandlerFailedServiceErrors" metric_name = "ClientConnectHandlerFailedServiceErrors" statistic = "Sum" threshold = "100" comparison_operator = "GreaterThanOrEqualToThreshold" alarm_description = "クライアント VPN エンドポイントへの接続用のクライアント接続ハンドラの実行中に発生したサービス側のエラーの数" } } }
main.tf
provider "aws" { region = "ap-northeast-1" } terraform { required_version = ">= 0.13" backend "s3" { bucket = "***********" key = "network/terraform.tfstate" region = "ap-northeast-1" } } data "aws_acm_certificate" "issued" { domain = "server" statuses = ["ISSUED"] }
VPN Client
AWS Client VPN Self-Service Portalにログインし、接続設定用ファイルと使用OS毎にClientVPNアプリケーションをダウンロードする
設定手順
- AWS Client VPN for Windows
- 3.[Add Profile (プロファイルの追加)] を選択します。の個所で保存したプロファイルを指定する
- AWS Client VPN for macOS
- 3.[Add Profile (プロファイルの追加)] を選択します。の個所で保存したプロファイルを指定する
- Linux の AWS Client VPN
- 3.[Add Profile (プロファイルの追加)] を選択します。の個所で保存したプロファイルを指定する
- AWS Client VPN for Windows
確認
- 使用中IPがNatGatewayに使用しているEIPであることが確認できればOKです