Ansible を用い、IP アドレス一つ + 無料のサーバ証明書で仮想 web ホスト群を構成する


IP アドレス一つでも、SNI (Server Name Indication) を使えば HTTPS で仮想ホストを立てることができます。それを nginx で、サーバ証明書としては無料の Let's Encrypt を用いて、Ansible で自動設定をしてみます。もちろん、証明書の更新も自動になる予定です。

> 動機としては、ownCloud の公式 Docker コンテナ（[https://hub.docker.com/_/owncloud/](https://hub.docker.com/_/owncloud/)）が、どうしても URL のパスとして “/” 直下で動くことを期待している。そして、それをサーバ証明書を持ったリバースプロキシで “/owncloud/” 等のサブディレクトリへ proxy しようとすると、どうしても壊れる。なけなしの TLS  サーバのルートを専有されても困るのである。

さて、[Let's Encrypt](https://letsencrypt.jp/) の登場によって、サーバ証明書の取得自体は自動化・無料化されていて、ありがたい限りです。とはいえ、貧乏なので IP アドレスは一つしかありません。従来は SSL/TLS + HTTP の特性上、SSL 通信を確立した後でないと HTTP はアクセス先のホスト名を渡さないので、HTTPS のサイトを複数持つには、サイトと同じ数だけの IP アドレスを持つことが必要でした。そんなご無体な。

そこで、SNI が登場しました。SNI は、SSL のハンドシェイク時にサーバへ「アクセス先のホスト名」を渡すことで、IP アドレスを専有せずにサーバ認証をすることができる仕様です。TLS 1.1 から導入されており、Let's Encrypt も SNI には対応しています。 ∥ [Server Name Indication - Wikipedia](http://ja.wikipedia.org/wiki/Server_Name_Indication)

しかし、クライアント側の対応に問題がありました。Windows XP が対応していなかったんです。Windows XP までの TLS/SSL ライブラリが SNI をサポートしなかったので、Firefox や Chrome (Chromium) などは自前の SSL クライアントライブラリを用いて SNI を実現していた（Chromium のクラス SSLClientSocketWin と SSLClientSocketNSS 等）のですが、例によって IE がサポートしていませんでした。 ∥ [Network Security Services | MDN](https://developer.mozilla.org/ja/docs/NSS)

しかし、そんな Windows XP も EOL を迎えたことで、まあだいたいのクライアントでは問題ないし、まして自家用にサイトを立てたい場合には何ら躊躇することはなかろうと思わますので、ガンガン使って行きたいと思います。

# nginx リバースプロキシでの設定方法

毎度毎サーバで手設定をするのも無駄なので、Ansible で書いてみます。一式、右記に置いてあります。 ∥ [knaka/nginx: Nginx role for Ansible](https://github.com/knaka/nginx)

使い方は、特に変哲はないです。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 をここに設定して

ですので、ディレクトリ構成としては、最終的にはこんな感じにしています。

~~~~text:
/
  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](https://community.letsencrypt.org/t/support-for-ports-other-than-80-and-443/3419/68)

Ansible の Role の主要部としては、以下のようになっています。Jinja2 テンプレート、便利ですね。

~~~~roles/nginx/tasks/main.yml
---

- 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 アドレスを返すスクリプトを仕込んでおきます。

以下のようにして、

~~~~roles/docker/tasks/main.yml
...

- copy:
    src: get_container_addr
    dest: /usr/local/bin/get_container_addr
    mode: 0755

...
~~~~

下記のようなスクリプトを流し込みます。

~~~~roles/docker/files/get_container_addr
#!/bin/sh
docker inspect --format '{{.NetworkSettings.IPAddress}}' "$1"
~~~~

この程度のことはインラインで書いても良かろうと思われるかも知れませんが、`docker inspect` の際の `--format` に指定する Go テンプレート（[template - The Go Programming Language](https://golang.org/pkg/text/template/)）のテンプレート書式のブレースが、Ansible の利用している Jinja2 テンプレートのプレースホルダの書式とカチ合うので、これ、Ansible の Playbook 内に書くと極めて煩雑になるので、別スクリプトとして入れてあります。まあ、どうせ他でも使うしね。 ∥ [docker - Escaping double curly braces in Ansible - Stack Overflow](http://stackoverflow.com/questions/32279519/escaping-double-curly-braces-in-ansible)

そして以下のように Docker コンテナを `run` した後で、当該コンテナが expose しているアドレス・ポートに対しての web proxy を設定します。Ansible にお願いすれば、コンテナのアドレス管理を人間がせずに済むのでスッキリですね。

~~~~roles/owncloud/tasks/main.yml
...

- 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 するだけです。

~~~~text:roles/owncloud/templates/proxy.conf.j2
location / {
  proxy_pass http://{{owncloud_addr}}/;
  proxy_set_header Host $host;
  proxy_buffering off;
}
~~~~

その際のインベントリは、以下のようになっています。ownCloud をプロキシする仮想ホスト名を `hostname` として渡してやるだけですね。

~~~~yaml:
[hosts_owncloud]
host0.example.com hostname="oc.example.om"
~~~~

はい、スッキリしました。