【証明書自動更新対応版】Oracle CloudとCloudflareとLet's Encryptでつくる！HSTSプリロード対応APサーバクラスタ

# イントロダクション

## この記事の概要
以下の作業について、細ぎれに別記事にしていたものを一気通貫の手順メモとしてひとつの記事にまとめたもの。

1. (OCI) Oracle Linux 8のインスタンスをふたつ構築する
2. (OCI) ふたつのインスタンスそれぞれにHTTPサーバとJavaEEコンテナを構築する
3. (OCI) ロードバランサを構築する
4. (OCI + Cloudflare + Let's Encrypt) 常時SSL/TLS化、HSTSプリロード対応する
5. (OCI + Cloudflare + Let's Encrypt) 証明書の更新を自動化する

## 最終的なアーキテクチャ
![qiita_20200922.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/b5537cab-a0ce-3a55-c90c-8d7d466ba1f3.jpeg)

「example.com」というドメインをXdomainで取得したものとする。
1. クライアントが「example.com」にアクセス。DNSサーバはCloudflareのNSサーバ名を返却。
2. クライアントがCloudflareのNSサーバにアクセス。NSサーバは「example.com」のIPアドレス（＝ロードバランサのグローバルIPアドレス）を返却。
3. クライアントがロードバランサのグローバルIPアドレスにアクセス。ロードバランサはバックエンドのインスタンスAとBのどちらかにリクエストを振り分け、処理を振り分けられたほうのインスタンスがレスポンスを返却。クライアントとロードバランサ間の通信はSSL/TLSで暗号化される。

HSTSプリロードに向けてワイルドカード証明書を作成するため、かつ証明書を自動更新するためにXdomainで取得したドメインのNSレコードをCloudflareに向けている。[^1]

# 1. Oracle Linux 8のインスタンスをふたつ構築する
## 0. 前提
* OS: Oracle Linux Server release 8.2
* OCIダッシュボードからインスタンスをたてるときの操作方法は省略
* コマンドの実行ユーザは特記しないかぎり```root```ユーザ

今回はOracle Linux 8にしたが手順に影響はないため、CentOSとかでもOK。同じ構成のインスタンスをつくるので、以降のコマンドもふたつのインスタンスそれぞれで実行する。（構成スクリプトを使えばラクチンなのかも）

## 1. アップデートする
とりあえず。

```
yum update -y
```

## 2. OSのファイアウォールを止める
OCIのファイアウォール設定に一本化するのでOSのファイアウォールは止める。

```
systemctl disable firewalld
systemctl stop firewalld
```

## 3. SELinuxを無効化する
```
vi /etc/sysconfig/selinux

#SELINUX=enforcing
SELINUX=disabled
```
ここでOSを再起動しておく。

```
reboot
```

## 4. SSHの設定をする
パブリックIPを設定している場合、デフォルトの22番ポートを開けておくのはリスキーなので、ポートを変更する。

```
vi /etc/ssh/sshd_config

#Port 22
Port 10022
```
rootユーザでのログインも禁止する。

```
vi /etc/ssh/sshd_config

#PermitRootLogin yes
PermitRootLogin no
```
設定を反映するためSSHサーバを再起動する。

```
systemctl restart sshd
```

変更したポートに併せてOCIのファイアウォール設定も変える。

## 5. エイリアスを張る
よく使うエイリアスを忘れないうちに設定しておく。

```
echo "alias tailf='tail -f'" >> /etc/bashrc
source /etc/bashrc

which tailf
```

# 2. ふたつのインスタンスそれぞれにHTTPサーバとJavaEEコンテナを構築する
## 0. 前提
* Java：OpenJDK 1.8.0_265
* HTTPサーバ：Apache 2.4.37
* JavaEEコンテナ：Wildfly 12.0.3.Final
* コマンドの実行ユーザは特記しないかぎり```root```ユーザ

## 1. Wildflyを構築する
### 1. JDKをインストールする

```
yum install -y java-1.8.0-openjdk.x86_64
```

### 2. Wildfly実行用OSユーザを作成する

```
groupadd -r wildfly
useradd -r -g wildfly -d /opt/wildfly -s /sbin/nologin wildfly
```

### 3. Wildflyをインストールする
```
cd /opt
wget https://download.jboss.org/wildfly/20.0.1.Final/wildfly-20.0.1.Final.zip
unzip -q wildfly-20.0.1.Final.zip
ln -s wildfly-20.0.1.Final wildfly
```

### 4. Wildflyをデーモン化する
デーモン起動用のスクリプトはすでに用意されているので配置して少し内容を書き換えるだけ。

まずは定義ファイルを配置する。

```
mkdir -p /etc/wildfly
cp /opt/wildfly/docs/contrib/scripts/systemd/wildfly.conf /etc/wildfly/
```

WildflyへはHTTPサーバ経由でアクセスするためバインドアドレスを変更する。

```
vi /etc/wildfly/wildfly.conf

#WILDFLY_BIND=0.0.0.0
WILDFLY_BIND=127.0.0.1
```

起動シェルとサービスファイルを配置する。

```
cp /opt/wildfly/docs/contrib/scripts/systemd/launch.sh /opt/wildfly/bin/
chmod 744 /opt/wildfly/bin/launch.sh
cp /opt/wildfly/docs/contrib/scripts/systemd/wildfly.service /etc/systemd/system/
chown -R wildfly /opt/wildfly*
```

デーモンとして反映し、自動起動を有効化する。

```
systemctl daemon-reload
systemctl start wildfly
systemctl enable wildfly
```

## 2. HTTPサーバを構築する
### 1. Apache HTTP Serverをインストールする

```
yum install -y httpd
```

### 2. セキュリティ対策を行なう
Apache HTTP Serverはデフォルト設定のままだとセキュリティリスクがあるので是正する。

#### デフォルトコンテンツを削除する
通常公開しないウェルカムページなどの不要コンテンツは極力排除する。

```
cd /etc/httpd/conf.d
mv welcome.conf welcome.conf.org
mv autoindex.conf autoindex.conf.org
```

#### ディレクトリ内容一覧表示機能を無効化する

```
vi /etc/httpd/conf/httpd.conf

#Options Indexes FollowSymLinks
Options FollowSymLinks
```

#### TRACEメソッドを無効化する
XST対策としてTRACEメソッドを無効化する。

```
vi /etc/httpd/conf/httpd.conf

# ファイル末尾に追記
TraceEnable off
```

#### バージョン情報表示機能を無効化する
HTTPレスポンスヘッダにWebサーバのバージョンを含まないようにする。

```
vi /etc/httpd/conf/httpd.conf

# ファイル末尾に追記
ServerTokens ProductOnly
ServerSignature off
```

#### フレーム内ページ表示を同一ドメイン内のみに許可する
クリックジャッキング対策として、HTTPレスポンスヘッダにX-Frame-Optionsヘッダを追加する。

```
# 新規にファイル作成
vi /etc/httpd/conf.modules.d/headers.conf

# ファイル末尾に追記
Header append X-FRAME-OPTIONS SAMEORIGIN
```

## 3. HTTPサーバとWildflyを連携させる
HTTPサーバへのリクエストをWildflyへ引き込む。

### 1. リバースプロキシを設定する
80番ポートへのアクセスを8080番ポート（WildflyのHTTPリスナ）へ向けます。

```
# 新規にファイル追加
vi /etc/httpd/conf.modules.d/wildfly.conf

<VirtualHost *:80>
  ProxyPass / http://127.0.0.1:8080/
  ProxyPassReverse / http://example.com/
</VirtualHost>
```

### 2. 設定を反映する
定義ファイルをテストしてHTTPサーバを再起動する。

```
httpd -t
systemctl restart httpd
```

### 3. HTTPサーバをデーモン化する

```
systemctl enable httpd
```

# 3. ロードバランサを構築する

## 1. ロードバランサを作成する
OCIダッシュボードへアクセスし、「ネットワーキング」->「ロード・バランサ」へ遷移する。

「ロード・バランサの作成」を押下する。
![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/12c1c83e-f7a4-4941-c14c-be33f92d0dfd.png)

### 詳細の追加
各項目に下記のように入力する。

* ロード・バランサ名：＜任意＞
* 可視性タイプの選択：パブリック
* 合計帯域幅の選択：マイクロ
* （ネットワーキングの選択）仮想クラウド・ネットワーク：＜先に作成したインスタンスと同じVCN＞
* （ネットワーキングの選択）サブネット：パブリック・サブネット

「次」を押下する。

### バックエンドの選択
各項目に下記のように入力する。

* ロード・バランシング・ポリシーの指定：重み付けラウンド・ロビン
* （ヘルス・チェック・ポリシーの指定）プロトコル：HTTP
* （ヘルス・チェック・ポリシーの指定）ポート：80
* （ヘルス・チェック・ポリシーの指定）間隔：10000
* （ヘルス・チェック・ポリシーの指定）タイムアウト：3000
* （ヘルス・チェック・ポリシーの指定）再試行回数：3
* （ヘルス・チェック・ポリシーの指定）ステータス・コード：200
* （ヘルス・チェック・ポリシーの指定）URLパス：/healthcheck.html

「SSLの使用」にはまだチェックを入れない。

「バックエンドの追加」を押下して、先に作成したインスタンスふたつにチェックを入れて「選択したバックエンドの追加」を押下する。
「バックエンド・サーバの選択」->「ポート」には両方とも```80```を入力する。
![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/a42ac42a-4e69-d4f3-f4cc-6b90e432fc17.png)

「次」を押下する。

### リスナーの構成
各項目に下記のように入力する。

* リスナー名：＜任意＞
* リスナーで処理するトラフィックのタイプを指定します：HTTP
* リスナーでイングレス・トラフィックをモニターするポートを指定します：80

「送信」を押下する。

## 2. ヘルスチェック用コンテンツを配置する
ふたつのインスタンスのドキュメントルートに、死活監視用の```healthcheck.html```を作成する[^2]。
OCIのロードバランサの仕様が不明だが0バイトだと不安なので、中身はHTMLぽい内容にしておく。

```
# 新規にファイル追加
vi /var/www/html/healthcheck.html

<!DOCTYPE html>
<html>
  <head>
    <title>Healthy</title>
  </head>
  <body>
  </body>
</html>
```

プロキシの設定により、HTTPサーバへのアクセスはすべてWildflyへ引き込まれるので、このままでは```/healthcheck.html```宛のリクエストもWildflyまで引き込まれた結果ステータスコードは```404```が返ってしまう。

なので、```/healthcheck.html```宛のリクエストはHTTPサーバで処理されて折り返すように設定を変更する。

```
vi /etc/httpd/conf.modules.d/wildfly.conf

<VirtualHost *:80>
  # これを追記
  ProxyPass /healthcheck.html !

  ProxyPass / http://127.0.0.1:8080/
  ProxyPassReverse / http://127.0.0.1:8080/
</VirtualHost>
```

設定を反映するためHTTPサーバを再起動する。

```
httpd -t
systemctl restart httpd
```

## 3. 動作確認をとる
HTTPサーバのアクセスログを確認し、ロードバランサのポーリングに対してステータスコード```200```を返していることを確認する。

```
tail -f /var/log/httpd/access_log
```

ステータスコード```404```が返っている場合やそもそもポーリングがHTTPサーバまで届いていない場合は、ヘルスチェック用コンテンツのファイル名やアクセス権限、```wildfly.conf```の内容、OCIのファイアウォール設定を見直すこと。

どちらのHTTPサーバもステータスコード```200```を返すようになってしばらくすると、OCIダッシュボード上でヘルスチェックOKになったことが確認できる。
![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/093c4cdf-dadb-3a8d-9d37-68de8c51fc17.png)

# 4. 常時SSL/TLS化、HSTSプリロード対応する
## 0. 前提
* certbot：1.7.0-1.el8
* python3-certbot-dns-cloudflare：1.7.0-1.el8

## 1. ドメインのネームサーバを変更する
前準備として、ドメインのネームサーバをCloudflareに向けておく（[参考](https://support.cloudflare.com/hc/ja/articles/205195708-%E3%83%89%E3%83%A1%E3%82%A4%E3%83%B3%E3%81%AE%E3%83%8D%E3%83%BC%E3%83%A0%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%82%92Cloudflare%E3%81%AB%E5%A4%89%E6%9B%B4%E3%81%99%E3%82%8B)）。

## 2. サーバ証明書を作成する
証明書作成作業は任意のLinux環境で実施する。今回はロードバランサ配下のインスタンスAで行なう。

証明書はLet's Encryptで作成するため```certbot```と、dns-01チャレンジに便利なCloudflareプラグインをインストールする。

```
yum --enablerepo=ol8_developer_EPEL install certbot python3-certbot-dns-cloudflare
```

Cloudflareアクセス用に設定ファイルを作成する。

```
cd /etc/letsencrypt
touch cloudflare.ini
chmod 600 cloudflare.ini
vi cloudflare.ini

dns_cloudflare_email = <Cloudflareに登録したメールアドレス>
dns_cloudflare_api_key = <Cloudflareマイページから確認できるAPIキー>
```

証明書を作成する。

```
certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini -d *.example.net
```

CloudflareのダッシュボードにアクセスしてドメインのTXTレコードを確認するとトークンが勝手に設定されているのがわかる。```certbot```コマンド実行時に発行されたトークンをCloudflareのAPIでTXTレコードに埋め込んでいて、Let's Encryptは発行したトークンと対象のドメインのTXTレコードが一致することを確認することで```certbot```コマンド実行ユーザがドメインの管理者であることを確認している。

証明書が作成されたことを確認する。

```
ll /etc/letsencrypt/live/example.com

cert.pem
chain.pem
fullchain.pem
privkey.pem
```

## 3. ロードバランサに証明書を設定する
SSL/TLS化はSSL/TLSアクセラレーションで構成する。

OCIダッシュボードから、先につくったロードバランサの詳細ページにアクセスする。

### 証明書を追加する
「証明書」へ遷移して、「証明書の追加」を押下する。

各項目に下記のように入力する。

* 証明書名：＜任意＞
* SSL証明書：```cert.pem```をアップロードする
* CA証明書：```fullchain.pem```をアップロードする
* 秘密キー：```privkey.pem```をアップロードする

「証明書の追加」を押下する。

### HTTPSリスナーを作成する
「リスナー」へ遷移して、「リスナーの作成」を押下する。

各項目に下記のように入力する。

* 名前：＜任意＞
* プロトコル：HTTP
* ポート：443
* SSLの使用：チェックを入れる
* 証明書名：＜先に作成した証明書名＞
* バックエンド・セット：＜先に作成したバックエンド・セット名＞

「リスナーの作成」を押下する。

### 常時SSL/TLS化する
Cloudflareダッシュボードの「ページルール」から「HTTPS の自動リライト」設定をオンにする。これで```http://*.example.com```へのアクセスは、Cloudflareによって```https://*.example.com```にリダイレクトされる。

ロードバランサのリスナーのうち、ロードバランサ作成時につくった80番ポートでリッスンするリスナーは不要なので削除する。

### HSTSプリロード対応する
ドメインをHSTSプリロードリストに登録する条件は下記のとおり。

* ■　SSL/TLS証明書が有効であること
* ■　常時SSL/TLS化されていること
* ■　サブドメインも含めてSSL/TLS化されていること
* □　HSTSヘッダが設置されていること

ここまでの手順で４つめ以外は満たしているので、仕上げにHSTSヘッダを設置する。

#### ルール・セットを作成する
OCIダッシュボードから、ロードバランサの詳細ページへ遷移する。
「ルール・セット」へ遷移して、「ルール・セットの作成」を押下する。

各項目に下記のように入力する。

* 名前：＜任意＞
* レスポンス・ヘッダー・ルールの指定：チェックを入れる
* アクション：レスポンス・ヘッダーの指定
* ヘッダー：```Strict-Transport-Security```
* 値：```max-age=31536000; includeSubDomains; preload```

「変更の保存」を押下する。

#### HSTSヘッダを設置する
「リスナー」へ遷移し、先に作成した443番ポートでリッスンするHTTPリスナの「編集」を押下する。

「＋追加ルール・セット」を押下し、ルール・セットに先に作成したルール・セット名を選択する。

「リスナーの更新」を押下する。

#### 動作確認をとる＆HSTSプリロードリストへ登録する
https://hstspreload.org/ でドメインを入力することでHSTSプリロードに正しく対応できているかテストできる。
テストに通過する（画面が緑色になる）と画面下部からそのままリストへ登録申請できる。

# 5. 証明書の更新を自動化する
証明書の更新自体は```certbot renew```をcronに登録すれば簡単だが、問題は作成した証明書をOCIにアップロードして、ロードバランサのリスナーに登録すること。

## 1. OCI CLIをインストールする
OCI CLIをインストールする。（参考：[公式ドキュメント](https://docs.cloud.oracle.com/ja-jp/iaas/Content/API/SDKDocs/cliinstall.htm#PromptsInstall)）

```
bash -c "$(curl -L https://raw.githubusercontent.com/oracle/oci-cli/master/scripts/install/install.sh)"
```

インストール完了後、初期セットアップを行なう。

```
oci setup config
```

## 2. oci-update-lb-certificateをインストールする
OCI CLIをシェルから呼び出して証明書をアップロードする場合、設定が反映されるまでのウェイトなど考慮点が多いため、証明書の自動更新に特化したツールを作成した。

[GitHub](https://github.com/syake-salmon/oci-update-lb-certificate/releases)から最新版をダウンロードする。使用方法も[README
](https://github.com/syake-salmon/oci-update-lb-certificate/blob/master/README.md)を参照のこと。

下記のようなコマンドでOCIへ証明書をアップロードして、かつリスナーに設定することができる。

```
java -jar oci-update-lb-certificate.jar           \
 --certificate-name <作成する証明書名>              \
 --listener-name <更新対象のリスナ名>               \
 --load-balancer-id <更新対象のロードバランサのOCID> \
 --private-key-file <privkey.pemのパス>            \
 --public-certificate-file <cert.pemのパス>        \
 --ca-certificate-file <fullchain.pemのパス>       \
 --config-file <OCI CLIのコンフィグファイルのパス>
```

証明書名はロードバランサ内で一意である必要があるので、上記コマンドをシェルスクリプト化して、動的に証明書名を設定すればよい（たとえば日付とか）。

## 3. 証明書の更新＆OCIへの適用を試す
証明書の更新は```certbot renew```で行ない、更新した証明書をoci-update-lb-certificateでOCIに適用する。下記コマンドで試せる。

```
certbot --force-renewal renew --post-hook "sh <oci-update-lb-certificate.jarを実行するシェル>"
```

オプション```--post-hook```は証明書が更新できたときだけ指定のコマンドを実行する。証明書を作成したばかりだと更新に失敗するので、オプション```--force-renewal```を指定して証明書更新を強制する。
対して、```--dns-cloudflare-credentials```や```-d```など証明書作成時に指定したオプションは```/etc/letsencrypt/renewal/example.com.conf```の内容を読んでいるので更新時に指定する必要はない。```--post-hook```オプションも上記コマンド実行後に同じ定義ファイルに刻まれるので、二度目以降の更新時は指定しなくてよくなる。

コマンドが正常に終了したらhttps://example.com にアクセスする。証明書のエラー等をブラウザに警告されなければOK。

## 4. 証明書の更新＆OCIへの適用を自動化する
cronにタスクを追加して自動化する。

```
crontab -e

00 04 * * * root /bin/certbot renew
```

上記例では毎日4時ごろに証明書の更新を試みる。```--force-renewal```オプションはLet's Encryptのサーバによけいな負荷をかけるのでcron実行時はつけてはいけない。

# まとめ
CloudflareやLet's Encryptが非常に便利なので、SSL/TLS化、HSTSプリロード対応はあまり難しくない。肝は更新した証明書をOCIに自動適用する部分で、OCI Java SDKのドキュメントが少ないのもあってだいぶ苦戦した。。。

# 参考文献
* [私がCentOS 7を入れたら最初にやること](https://qiita.com/fetaro/items/8cf5169dc4d8a7ac9af6) by @smicle 
* [CentOS7 minimalをinstallしたあと最低限使える様にする](https://qiita.com/smicle/items/8556fcc820b870db891e) by @fetaro 
* [攻撃を受ける前に見直すApacheの基本的なセキュリティ10のポイント](https://www.rem-system.com/apache-security01/)

[^1]: Let's Encryptでワイルドカード証明書を作成するにはdns-01チャレンジが必須。dns-01はTXTレコードにトークンを埋める必要があり、証明書更新を自動化する場合はTXTレコードの更新も自動で行なわないといけない。CloudflareはTXTレコードを更新するAPIを公開しており、自動化に適している
[^2]: 本来はWildflyも含めて死活監視すべきなので、Wildfly上にヘルスチェック用コンテンツを配置するのが正しい