ENECHANGE Developer Blog

ENECHANGE開発者ブログ

新しくVPNを構築した件

CTO室のkazです。2022年最初の投稿は「WeWork東京スクエアガーデンへの本社機能移転決議」の裏で業務に支障が出ないよう、仕組みを考え、検証して構築する。そしてそれをみんなにつかってもらうタスクの1つであるVPNを紹介します。

状況

現オフィスでは某ベンダーのネットワークアプライアンスを導入して運用しています。 4年半前に20名ほどが準備期間1ヶ月ほどで移転してきたのですが、将来的な採用計画が不透明な状態の中、まぁ、ざっくり100名程度の処理をさばける性能であればいいかなというアバウトな要件で構築したのですが、幸い100名を超える常時接続となった現在でもH/Wリソースには余裕があり、安定的に運用できています。(オンプレで予め確保していた導入時では過剰と思われたリソースも、短期間のうちに成長していくスタートアップのケースでは刺さる)

目的

今回の移転では全従業員がテレワーク中心の働き方へとシフトしたので現在よりも機密性と可用性を向上させるとともに社内NW担当者(自分自身)の高止まりしている運用負荷を下げることがゴールです。

要件

要件としては大きく2点。

  1. 300名程度(現在のおよそ3倍)が同時接続し遅延なく使用できること
  2. マルチプラットフォームであること

方法

AWS ClientVPN と Azure Active Directory (Azure AD) を統合させます。 フルマネージドサービスだと不具合や障害調査など権限の問題もあり、解決や状況把握に時間を要するケースがあった為、今回はAWS Client VPNを構築し、すべて管理下に置くことにしました。

構成

f:id:dev-enechange:20220124150740p:plain
flow_image

IdP

  • Microsoft Azure

    1. AWS ClientVPNへの接続ユーザをAzureADで制御
    2. Azure ADアカウントを用いてAWS ClientVPN に自動的にサインインさせる

SP

  • AWS Client VPN

    1. セルフサービスポータル URLを作成することで運用コストを下げる
    2. 認証はfederated-authenticationを選択
    3. VPNを作成するVPCはDNS解決、DNSホストを有効にしておく

構築

Azure

Azure Active Directory シングル サインオン (SSO) と AWS ClientVPN の統合を参照する

  • Azure Active Directory > Enterprise applications > New application

    f:id:dev-enechange:20220124154219p:plain
    new app

  • aws clientvpnで検索しアプリケーションを選択し作成する

    f:id:dev-enechange:20220124154513p:plain
    filter app
    f:id:dev-enechange:20220124154621p:plain

  • ユーザを登録

    f:id:dev-enechange:20220124154721p:plain
    add user

  • Set up single sign on > SAML

    f:id:dev-enechange:20220124154805p:plain
    set sso

  • パラメータ入力

    f:id:dev-enechange:20220124154930p:plain
    parameter

  • App registrations > AWS ClientVPN f:id:dev-enechange:20220124155106p:plain

  • Manifest > > 応答URLをhttpに更新

    f:id:dev-enechange:20220124155151p:plain
    update manifest

  • Federation Metadata XMLをダウンロードする

    f:id:dev-enechange:20220124155351p:plain
    metadata download


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アプリケーションをダウンロードする

    f:id:dev-enechange:20220124170719p:plain
    self-service portal

  • 設定手順

    • AWS Client VPN for Windows
      • 3.[Add Profile (プロファイルの追加)] を選択します。の個所で保存したプロファイルを指定する
    • AWS Client VPN for macOS
      • 3.[Add Profile (プロファイルの追加)] を選択します。の個所で保存したプロファイルを指定する
    • Linux の AWS Client VPN
      • 3.[Add Profile (プロファイルの追加)] を選択します。の個所で保存したプロファイルを指定する
  • 確認

    • 使用中IPがNatGatewayに使用しているEIPであることが確認できればOKです