AWSで相互認証方式のクライアントVPN構築

## 目次
1. [構成図](#1-構成図)  
2. [VPN構築](#2-vpn構築)
    2.1. [サーバー証明書とクライアント証明書を発行し、ACMにインポートする](#21-サーバー証明書とクライアント証明書を発行しacmにインポートする)  
    2.2. [TerraformでVPN構築](#22-terraformでvpn構築)  
3. [クライアントサイドの接続方法](#3-クライアントサイドの接続方法)  
    3.1. [VPN接続プロファイルの準備](#31-vpn接続プロファイルの準備)    
    3.2. [AWS-Client-VPNをインストールし、プロファイルを登録する](#32-aws-client-vpnをインストールしプロファイルを登録する)    
    3.3. [接続先のプロファイルを選択して「接続」](#33-接続先のプロファイルを選択して接続)    
4. [クライアント管理](#4-クライアント管理)  
    4.1. [クライアント追加](#41-クライアント追加)    
    4.2. [クライアント失効](#42-クライアント失効)    
5. [証明書更新](#5-証明書更新)  
    5.1. [サーバー証明書更新](#51-サーバー証明書更新)
    5.2. [クライアント証明書更新](#52-クライアント証明書更新)
6. [費用節約のために](#6-費用節約のために)  

## 1. 構成図  
![VPN.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70866/a2e04b73-4e7f-1c61-0a49-434f5bc5952d.png)

構成上のポイント  
- VPNの認証方式は相互認証
- プロジェクト要件としてVPN接続時にインターネット接続可能とし、かつインターネット接続する際のIPは固定にする必要があった。そのため、Public SubnetにNAT GWを配置し、NAT GWにElasticIPをアタッチする構成とした  
- Win端末のメンバーとMac端末のメンバー両方がいた。Win端末のみVPC内部リソースのドメイン解決をできない事象が発生した。VPNエンドポイントのDNSサーバー設定に接続するVPCのDNSサーバーのリザーブドIP(VPC CIDR末尾2)を登録することで解消した

## 2. VPN構築  
### 2.1. サーバー証明書とクライアント証明書を発行し、ACMにインポートする
以下の手順は[AWSサイト](https://docs.aws.amazon.com/ja_jp/vpn/latest/clientvpn-admin/client-auth-mutual-enable.html)を参考にした 


:::note
サーバーおよびクライアント証明書の有効期限はデフォルトで約2年。クライアントが少ない場合は都度更新でもあまり手間がかからないかもしれないが、クライアントが多い場合は更新作業が手間なので、有効期限を長く設定しておく方が管理は楽。ただし、証明書の失効管理を適切に行い、暗号化アルゴリズムが長い期間の経過により陳腐化していないか注意する必要がある。
下記の有効期限を変更する設定は、[こちら](https://blog.serverworks.co.jp/easy-rsa-100-years-certificate)のサイトを参考にした。
:::  
```
git clone https://github.com/OpenVPN/easy-rsa.git
cd easy-rsa/easyrsa3

### 有効期限をデフォルトから変更したい場合は実施 ###
cp vars.example vars
echo "set_var EASYRSA_CA_EXPIRE 36500" >> vars
echo "set_var EASYRSA_CERT_EXPIRE 36500" >> vars
echo "set_var EASYRSA_CRL_DAYS 36500" >> vars
mv vars pki/
############################################

./easyrsa init-pki
./easyrsa build-ca nopass
./easyrsa build-server-full server nopass
./easyrsa build-client-full client001.domain.tld 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/client001.domain.tld.crt ~/custom_folder
cp pki/private/client001.domain.tld.key ~/custom_folder
cd ~/custom_folder

aws acm import-certificate --certificate fileb://server.crt --private-key fileb://server.key --certificate-chain fileb://ca.crt
#サーバー証明書とクライアント証明書のCAが同じなので以下は省略してもOK
aws acm import-certificate --certificate fileb://client001.domain.tld.crt --private-key fileb://client001.domain.tld.key --certificate-chain fileb://ca.crt
```
### 2.2. TerraformでVPN構築
下記のコードはTerraformコードからVPNの箇所だけ抜粋したもの。
そのうち全量コードのgithubリンクを貼るかもしれません。
```terraform:vpn.tf
data "aws_acm_certificate" "server_certificate" {
  domain = "server"
}

resource "aws_ec2_client_vpn_endpoint" "main" {
  for_each = local.vpne

  description            = each.value.description
  server_certificate_arn = each.value.server_certificate_arn
  client_cidr_block      = each.value.client_cidr_block
  dns_servers            = each.value.dns_servers

  authentication_options {
    type                       = each.value.authentication_options.type
    root_certificate_chain_arn = each.value.authentication_options.root_certificate_chain_arn
  }

  connection_log_options {
    enabled               = each.value.connection_log_options.enabled
    cloudwatch_log_group  = each.value.connection_log_options.cloudwatch_log_group
    cloudwatch_log_stream = each.value.connection_log_options.cloudwatch_log_stream
  }

  tags = {
    Name = each.value.name
  }
}

resource "aws_ec2_client_vpn_network_association" "main" {
  for_each = local.vpn_network_association

  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main[each.value.vpne].id
  subnet_id              = aws_subnet.main[each.value.subnet].id
  lifecycle {
    ignore_changes = [subnet_id]
  }
}

resource "aws_ec2_client_vpn_route" "main" {
  for_each = local.vpn_route

  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main[each.value.client_vpn_endpoint].id
  destination_cidr_block = each.value.destination_cidr_block
  target_vpc_subnet_id   = aws_subnet.main[each.value.target_vpc_subnet].id

  depends_on = [
    aws_ec2_client_vpn_network_association.main
  ]
}

resource "aws_ec2_client_vpn_authorization_rule" "main" {
  for_each = local.vpn_auth

  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main[each.value.client_vpn_endpoint].id
  target_network_cidr    = each.value.target_network_cidr
  authorize_all_groups   = each.value.authorize_all_groups

  depends_on = [
    aws_ec2_client_vpn_network_association.main
  ]
}

```
```terraform:vpn_locals.tf
locals {
  vpne = {
    example01 = {
      name                   = "${var.prefix}-vpne-apne1-01"
      description            = "client vpn endpoint to connect example VPC"
      server_certificate_arn = data.aws_acm_certificate.server_certificate.arn
      client_cidr_block      = var.cidr_blocks.client_vpn
      dns_servers            = [var.ip_addresses.dns]
      authentication_options = {
        type                       = "certificate-authentication"
        root_certificate_chain_arn = data.aws_acm_certificate.server_certificate.arn
      }
      connection_log_options = {
        enabled               = true
        cloudwatch_log_group  = aws_cloudwatch_log_group.vpn.name
        cloudwatch_log_stream = aws_cloudwatch_log_stream.vpn.name
      }
    }
  }
  vpn_network_association = {
    example-pri-a = {
      vpne   = "example01"
      subnet = "example-pri-a"
    }
  }
  vpn_route = {
    example01 = {
      client_vpn_endpoint    = "example01"
      destination_cidr_block = var.cidr_blocks.global
      target_vpc_subnet      = "example-pri-a"
    }
    peering01 = {
      client_vpn_endpoint    = "example01"
      destination_cidr_block = var.cidr_blocks.peering01
      target_vpc_subnet      = "example-pri-a"
    }
  }
  vpn_auth = {
    vpc = {
      client_vpn_endpoint  = "example01"
      target_network_cidr  = aws_vpc.main["example01"].cidr_block
      authorize_all_groups = true
    }
    internet = {
      client_vpn_endpoint  = "example01"
      target_network_cidr  = var.cidr_blocks.global
      authorize_all_groups = true
    }
    peering01 = {
      client_vpn_endpoint  = "example01"
      target_network_cidr  = var.cidr_blocks.peering01
      authorize_all_groups = true
    }
  }
}

```
## 3. クライアントサイドの接続方法  
### 3.1. VPN接続プロファイルの準備  
#### 3.1.1. マネコンからクライアント設定をダウンロード  
`VPC > クライアントVPNエンドポイント > 対象のエンドポイント` を開いて、「クライアント設定をダウンロード」
#### 3.1.2. ダウンロードしたクライアント設定の末尾にクライアント証明書および鍵の情報を追記する  
(方法A) 手順2.1で発行したクライアント証明書およびクライアント証明書鍵の中身を以下の形式でダウンロードした設定ファイルの末尾に追記する
```
<cert>
クライアント証明書の中身
</cert>
<key>
クライアント証明書鍵の中身
</key>
```  
毎回手動でやるのが面倒なので、下記のようにシェル化していた
```
CLIENT=$1

cp -p example-vpn-client-config-base.ovpn example-vpn-client-config-${CLIENT}.ovpn
echo '<cert>' >> example-vpn-client-config-${CLIENT}.ovpn
cat client${CLIENT}.domain.tld.crt >> example-vpn-client-config-${CLIENT}.ovpn
echo '</cert>' >> example-vpn-client-config-${CLIENT}.ovpn

echo '<key>' >> example-vpn-client-config-${CLIENT}.ovpn
cat client${CLIENT}.domain.tld.key >> example-vpn-client-config-${CLIENT}.ovpn
echo '</key>' >> example-vpn-client-config-${CLIENT}.ovpn
```
(方法B) 手順2.1で発行したクライアント証明書およびクライアント証明書鍵を連携し、ダウンロードしたクライアント設定ファイルの末尾に以下のように追記する
```
cert <クライアント証明書のフルパス>
key <クライアント証明書鍵のフルパス>
```
:::note info  
こちらの場合証明書や鍵の場所が移動してしまった場合やファイル権限などの問題で、エラーとなってしまうケースもあるので、個人的には方法Aの方がお勧め  
:::

### 3.2. AWS Client VPNをインストールし、プロファイルを登録する
[こちら](https://aws.amazon.com/jp/vpn/client-vpn-download/)からAWS Client VPNをダウンロードし、インストール  
手順3.1.2で作成したプロファイルを登録する

### 3.3. 接続先のプロファイルを選択して「接続」
プルダウンから接続先のプロファイルを選んで接続する

## 4. クライアント管理  
### 4.1. クライアント追加  
メンバー追加などによりクライアントを追加したい場合はクライアント証明書を以下の手順で発行する
```
./easyrsa build-client-full clientXXX.domain.tld nopass

cp pki/issued/clientXXX.domain.tld.crt ~/custom_folder
cp pki/private/clientXXX.domain.tld.key ~/custom_folder
```
### 4.2. クライアント失効  
メンバー離任などによりクライアントを失効させたい場合はクライアント証明書を以下の手順でリボークする  
FULL_PATH_OF_CRL_PEMはcrl.pemファイルのフルパスが入る。クローンしたフォルダ下の `easy-rsa/easyrsa3/pki/crl.pem` に存在する。
```
./easyrsa revoke clientXXX.domain.tld
./easyrsa gen-crl

aws ec2 import-client-vpn-client-certificate-revocation-list --certificate-revocation-list file://${FULL_PATH_OF_CRL_PEM} --client-vpn-endpoint-id ${VPN_ENDPOINT_ID} --region ${REGION}
```
## 5. 証明書更新
### 5.1. サーバー証明書更新
サーバー証明書更新の手順は以下を参考にした。
- [AWS Client VPN 管理者ガイド](https://docs.aws.amazon.com/ja_jp/vpn/latest/clientvpn-admin/mutual-renew.html)
- [【AWS Client VPN】OpenVPN easy-rsaの自己証明書の更新手順（相互認証）](https://blog.serverworks.co.jp/open-vpn-easy-rsa-renew-certificate#%E8%A8%BC%E6%98%8E%E6%9B%B8%E3%81%AE%E6%9B%B4%E6%96%B0-1)
:::note
Easy-RSAのバージョンによってrenewコマンドがサポートされるかどうかが異なる。`./easyrsa help` でサポートされているコマンドを確認できる。
:::
:::note
デフォルトでは失効30日以内の証明書しか更新できないため、それよりも前に更新したい場合は、EASYRSA_CERT_RENEWの値を変更する
```
export EASYRSA_CERT_RENEW=365
```
また、証明書の有効期限は以下のコマンドで確認できる
```
openssl x509 -text -noout -in ${cert_file}
```
:::
```
# renewがサポートされている場合
./easyrsa renew server nopass

# renewがサポートされていない場合
./easyrsa expire server
./easyrsa --san=DNS:server sign-req server server

mkdir ~/custom_folder2
cp pki/ca.crt ~/custom_folder2/
cp pki/issued/server.crt ~/custom_folder2/
cp pki/private/server.key ~/custom_folder2/
cd ~/custom_folder2/

aws acm import-certificate --certificate fileb://server.crt --private-key fileb://server.key --certificate-chain fileb://ca.crt --certificate-arn arn:aws:acm:${region}:${accout_id}:certificate/${certificate_id}
```
### 5.2. クライアント証明書更新
```
# renewがサポートされている場合
./easyrsa renew clientXXX.domain.tld nopass

# renewがサポートされていない場合(すみませんが、推測です。。。)
./easyrsa expire clientXXX.domain.tld
./easyrsa --san=DNS:clientXXX.domain.tld sign-req clientXXX.domain.tld clientXXX.domain.tld

mkdir ~/custom_folder2
cp pki/issued/clientXXX.domain.tld.crt ~/custom_folder2
cp pki/private/clientXXX.domain.tld.key ~/custom_folder2
```
更新後には手順3に記載のVPN接続プロファイルを更新する必要あり

## 6. 費用節約のために  
使用しない時はNATおよびVPN Network Associationを削除し、使う時に再度terraformを実行して再構築すると多少節約になる。
担当したプロジェクトではCodeBuildでterraform実行できるように整備し、EventBridgeから定時でNATおよびVPN Network Associationの構築/削除を行なっていた。