Ansible を用い、IP アドレス一つ + 無料のサーバ証明書で仮想 web ホスト群を構成する
IP アドレス一つでも、SNI (Server Name Indication) を使えば HTTPS で仮想ホストを立てることができます。それを nginx で、サーバ証明書としては無料の Let's Encrypt を用いて、Ansible で自動設定をしてみます。もちろん、証明書の更新も自動になる予定です。
動機としては、ownCloud の公式 Docker コンテナ(https://hub.docker.com/_/owncloud/)が、どうしても URL のパスとして “/” 直下で動くことを期待している。そして、それをサーバ証明書を持ったリバースプロキシで “/owncloud/” 等のサブディレクトリへ proxy しようとすると、どうしても壊れる。なけなしの TLS サーバのルートを専有されても困るのである。
さて、Let's Encrypt の登場によって、サーバ証明書の取得自体は自動化・無料化されていて、ありがたい限りです。とはいえ、貧乏なので IP アドレスは一つしかありません。従来は SSL/TLS + HTTP の特性上、SSL 通信を確立した後でないと HTTP はアクセス先のホスト名を渡さないので、HTTPS のサイトを複数持つには、サイトと同じ数だけの IP アドレスを持つことが必要でした。そんなご無体な。
そこで、SNI が登場しました。SNI は、SSL のハンドシェイク時にサーバへ「アクセス先のホスト名」を渡すことで、IP アドレスを専有せずにサーバ認証をすることができる仕様です。TLS 1.1 から導入されており、Let's Encrypt も SNI には対応しています。 ∥ Server Name Indication - Wikipedia
しかし、クライアント側の対応に問題がありました。Windows XP が対応していなかったんです。Windows XP までの TLS/SSL ライブラリが SNI をサポートしなかったので、Firefox や Chrome (Chromium) などは自前の SSL クライアントライブラリを用いて SNI を実現していた(Chromium のクラス SSLClientSocketWin と SSLClientSocketNSS 等)のですが、例によって IE がサポートしていませんでした。 ∥ Network Security Services | MDN
しかし、そんな Windows XP も EOL を迎えたことで、まあだいたいのクライアントでは問題ないし、まして自家用にサイトを立てたい場合には何ら躊躇することはなかろうと思わますので、ガンガン使って行きたいと思います。
nginx リバースプロキシでの設定方法
毎度毎サーバで手設定をするのも無駄なので、Ansible で書いてみます。一式、右記に置いてあります。 ∥ knaka/nginx: Nginx role for Ansible
使い方は、特に変哲はないです。Ansible インベントリに、ホストごとに付与したいホスト名をリストで列挙しまして、
...
[hosts_nginx]
host0.example.com hostnames='["www.example.com", "oc.example.com"]'
host1.example.com hostnames='["oc2.example.com"]'
...
Playbook から呼びます。
- name: Nginx servers
hosts: hosts_nginx
become: true
roles:
- role: nginx
すると、https:/www.example.com/ や https://oc2.example.com/ などに TLS で通信できるという寸法です。
作る設定は、以下のような感じになります。
- 80 番ポートは、Let's Encrypt でのホスト認証専用にする。Let's Encrypt の認証のためのアクセス以外は、301 で 443 番ポートへリダイレクトする
- 443 番ポートは、SNI で TLS 通信をする
- ルートは /usr/share/nginx/html-{{hostname}}/ に、ホスト名ごとに用意してやる
- ホスト名ごとの設定は、/etc/nginx/sites-available/{{hostname}}.d/*.conf に置く。Ansible Playbook のこれ以降の箇所で、サービスを用意するたびに、好きに location をここに設定して
ですので、ディレクトリ構成としては、最終的にはこんな感じにしています。
/
etc/
letsencrypt-domains.txt # ホスト一覧
nginx/
sites_available/
letsencrypt.conf # 80 番設定
tls.conf # 443 番設定
www.example.com.d/ # 仮想ホストごとの設定
location_a.conf
proxy_b.conf
oc.example.com.d/ # もう一つ
location_x.conf
location_y.conf
usr/share/nginx/
html/
html-www.example.com/
html-oc.example.com/
要は、SSL/TLS の設定や nginx の基本設定の部分などは一旦決まってしまえばそれ以降はほとんどイジることはないが、プロキシの設定などの、server
ディレクティブ以下の location
などは何かと変更することが多いので、その部分を include
でワイルドカード include できるようにしているだけです。
80 番ポートで Let's Encrypt をしている点については、ここは再検討の余地がありますかね…。というのも、下手に設定をイジって(とりわけ SSL/TLS の方の設定をイジって)nginx 自体が起動できなくなると、Let's Encrypt の更新じたいが出来なくなってしまうからです。Let's Encrypt の standalone モード(host モードではなく)を使って、どこか適当なポート(8080 番あたり)で Let's Encrypt した方が良いかも知れません。
追記(Tue May 10 JST 2016): 当然それは出来ると思っていたのですが、いざやろうとしたらそれらしいオプションが無く、どうやら今のところまだ 80 番と 443 番のポートでしか受け付けられないようです。それは辛いなぁ。 ∥ Support for ports other than 80 and 443 - Feature Requests - Let's Encrypt Community Support
Ansible の Role の主要部としては、以下のようになっています。Jinja2 テンプレート、便利ですね。
---
- apt: name={{item}} state=present
with_items:
- git
- nginx
- file: dest=/etc/nginx/sites-enabled/default state=absent
## 80 番ポートは Let's Encrypt 専用とする
- template:
src: letsencrypt.conf.j2
dest: /etc/nginx/sites-available/letsencrypt.conf
mode: 0644
notify: "Restart Nginx"
- file:
src: /etc/nginx/sites-available/letsencrypt.conf
dest: /etc/nginx/sites-enabled/letsencrypt.conf
state: link
- name: Let's Encrypt is installed
git:
repo: https://github.com/letsencrypt/letsencrypt.git
dest: /opt/letsencrypt
version: HEAD
update: no
- copy:
content: --domain {{hostnames | join(" --domain ")}}
dest: /etc/letsencrypt-options.txt
notify: "Initialize Let's Encrypt"
- stat: path=/etc/letsencrypt/live/
register: stat_certdir
- command: /bin/true
notify: "Initialize Let's Encrypt"
when: stat_certdir.stat.isdir is not defined or not stat_certdir.stat.isdir
- meta: flush_handlers # Do it before configuring TLS which requires certs
- copy:
content: |
#!/bin/sh
/opt/letsencrypt/letsencrypt-auto renew \
--force-renew
service nginx restart
dest: /usr/local/bin/letsencrypt-renew
mode: 0755
- cron:
name: Let's Encrypt renewal task
job: "/usr/local/bin/letsencrypt-renew"
# dom: 1
dow: 0
hour: 0
minute: 0
# SSL/TLS を主とする
- name: Get installed version of Nginx
shell: "/usr/sbin/nginx -v"
changed_when: false
always_run: yes
register: _nginx_version
# - debug: msg="d0 {{_nginx_version}}"
- name: Create nginx_version variable
set_fact:
nginx_version: "{{_nginx_version.stderr.split()[2].split('/')[1]}}"
# - debug: msg="d0 {{nginx_version|version_compare('1.0', '>=')}}"
- shell: cp -a /usr/share/nginx/html/ /usr/share/nginx/html-{{item}}/
args:
creates: "/usr/share/nginx/html-{{item}}/"
with_items: "{{hostnames}}"
- name: Configuration directories for each site
file:
path: /etc/nginx/sites-available/{{item}}.d/
state: directory
mode: 0755
with_items: "{{hostnames}}"
- template:
src: tls.conf.j2
dest: /etc/nginx/sites-available/tls.conf
mode: 0644
notify:
- "Restart Nginx"
- file:
src: /etc/nginx/sites-available/tls.conf
dest: /etc/nginx/sites-enabled/tls.conf
state: link
要は、ホスト一覧が記載されている /etc/letsencrypt-options.txt が更新されるということはサーバ証明書の取得をする必要があるということであり、それさえ済んでしまえば、後は定期的に cron で証明書の renew をすれば良い、ということです。
nginx の設定は、以上です。
Docker へのプロキシ
次に、Docker コンテナとして ownCloud を動かして、nginx からそこへプロキシします。先述したとおり、このコンテナが「どうしても / 直下の TLS 通信をしたい」というので、SNI を設定した次第です。
さてと、私のところでは Docker をインストールする際に、Docker ホスト上に、Docker コンテナの IP アドレスを返すスクリプトを仕込んでおきます。
以下のようにして、
...
- copy:
src: get_container_addr
dest: /usr/local/bin/get_container_addr
mode: 0755
...
下記のようなスクリプトを流し込みます。
#!/bin/sh
docker inspect --format '{{.NetworkSettings.IPAddress}}' "$1"
この程度のことはインラインで書いても良かろうと思われるかも知れませんが、docker inspect
の際の --format
に指定する Go テンプレート(template - The Go Programming Language)のテンプレート書式のブレースが、Ansible の利用している Jinja2 テンプレートのプレースホルダの書式とカチ合うので、これ、Ansible の Playbook 内に書くと極めて煩雑になるので、別スクリプトとして入れてあります。まあ、どうせ他でも使うしね。 ∥ docker - Escaping double curly braces in Ansible - Stack Overflow
そして以下のように Docker コンテナを run
した後で、当該コンテナが expose しているアドレス・ポートに対しての web proxy を設定します。Ansible にお願いすれば、コンテナのアドレス管理を人間がせずに済むのでスッキリですね。
...
- name: ownCloud container
docker:
name: owncloud0
image: owncloud:9
state: started
expose:
- 80
volumes:
- owncloud:/var/www/html/
- /var/run/postgresql/:/var/run/postgresql/
restart_policy: always
- shell: get_container_addr owncloud0
changed_when: false
register: _owncloud_addr
- name: Set container addr
set_fact:
owncloud_addr: "{{_owncloud_addr.stdout}}"
- name: Reverse proxy
template:
src: proxy.conf.j2
dest: /etc/nginx/sites-available/{{hostname}}.d/proxy.conf
mode: 0644
notify: "Restart Nginx"
...
前項までで SNI での暗号化は済んでいますので、ここでやることは、下記のような location
ディレクティブを仕込んでサービスを restart するだけです。
location / {
proxy_pass http://{{owncloud_addr}}/;
proxy_set_header Host $host;
proxy_buffering off;
}
その際のインベントリは、以下のようになっています。ownCloud をプロキシする仮想ホスト名を hostname
として渡してやるだけですね。
[hosts_owncloud]
host0.example.com hostname="oc.example.om"
はい、スッキリしました。