Mastodon を Apache の名前ベースバーチャルホスト環境下で構築してみた

# はじめに

Mastodon (マストドン)、流行ってますよねー。
ということで [自宅のサーバ](https://mstdn.yamachan.org/) にインスタンスを構築してみました。

ただ、既に他の用途でも運用中のサーバなので既存システムとうまくすり合わせる必要があり、ちょっとハマったのでメモしてみます。

## 使用したソフトのバージョン
|項目      |詳細                |
|:---------|------------------:|
|Mastodon  |v1.2.2             |
|OS        |CentOS 7.3 (x86_64)|
|HTTPd     |Apache 2.4         |

もともと CentOS 7 (x86_64) + Apache 2.4 系の構成だったので、それに合わせる形を取りました。

## システム構成
![システム構成図](https://qiita-image-store.s3.amazonaws.com/0/177073/1e6deb42-877f-6c1f-1818-0d5c3ab392b8.png)

[Mastodon の公式解説](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Production-guide.md) では Nginx をフロントエンドとして用いていますが、既に入っている Apache にその役目を持たせました。
名前ベースのバーチャルホストにより、https://mstdn.yamachan.org/ としてアクセスされた場合は Mastodon に、https://tokyo.yamachan.org/ としてアクセスされた場合はこれまで既存 Web ページをそのまま表示するという形を取りました。

# 作業手順

## Let's Encrypt で Web サーバを SSL 化する

[Let's Encrypt](https://letsencrypt.org/) でサーバ証明書を入手して Apache の SSL/TLS 暗号化通信 (https) を有効化します。

まず、EPEL を導入し、Let's Encrypt のクライアント [certbot](https://certbot.eff.org/) を入れました。

```shell-session:Terminal
# yum install epel-release
# yum install certbot
```

証明書取得のみを行うため、引数 "certonly" 付きで実行。

```shell-session:Terminal
# certbot certonly
```

ドメイン名と Webroot (例 /var/www/html) を入力して証明書を取得します。
これを Mastodon 用・既存 Web ページ用それぞれのサブドメインに対して実施しました。

あとは、[公式の解説文書](https://letsencrypt.jp/docs/using.html#where-are-my-certificates) を見ながら設定していきました。

```shell-session:Terminal
# yum install mod_ssl
# vi /etc/httpd/conf.d/ssl.conf
```

まずは既存 Web ページ側で試してみて、SSL 通信が動作することだけ確認しておきます。

```apache:ssl.conf
SSLCertificateKeyFile   /etc/letsencrypt/live/tokyo.yamachan.org/privkey.pem
SSLCertificateFile   /etc/letsencrypt/live/tokyo.yamachan.org/cert.pem
SSLCertificateChainFile   /etc/letsencrypt/live/tokyo.yamachan.org/chain.pem
```

```shell-session:Terminal
# systemctl reload httpd
```

https://～ でアクセスしてみて無事に表示されれば、まずは一安心。

Let's Encrypt は定期更新が必要なので、適宜 cron.daily にでも入れておきましょう。
証明書の更新後は Apache の reload が必要な模様。

```sh:cron_script
#!/bin/sh
/usr/bin/certbot renew
/bin/systemctl reload httpd.service
```

## Mastodon を入れる

素直に [Docker あり版](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Docker-Guide.md) で入れることにします。

とりあえず SELinux は一時的に Permissive にしました。構築出来たら後で改めて Enforcing にします。

```shell-session:Terminal
# setenforce 0
```

### Docker を導入する

```shell-session:Terminal
# yum install docker
# systemctl enable docker
# systemctl start docker
```

Docker Compose とやらも要るんだとか。とりあえず [公式の手順](https://github.com/docker/compose/releases) に沿って cURL で入れました。

```shell-session:Terminal
# curl -L https://github.com/docker/compose/releases/download/1.12.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
```

一般ユーザで使いたいので、socket ファイルの所有グループを dockerroot に変えつつ、作業用ユーザを dockerroot に入れる。

```shell-session:Terminal
# gpasswd -a USERNAME dockerroot
# chgrp dockerroot /var/run/docker.sock
# su USERNAME
$ docker ps
```

エラーにならなければ大丈夫らしいとのこと。

### Mastodon 本体を導入する

[公式 GitHub](https://github.com/tootsuite/mastodon) からおもむろに落とします。

```shell-session:Terminal
# yum install git
$ git clone https://github.com/tootsuite/mastodon.git
```

現時点での最終安定版をチェックアウト。
https://github.com/tootsuite/mastodon/releases の最新のタグのものを落とすことになります。

```shell-session:Terminal
$ cd mastodon/
$ git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
```

環境設定を行います。
まず、docker-compose.yml を開いてコメントアウトされている volume を有効化 (データを永続化) します。

```yaml:docker-compose.yml
version: '2'
services:

  db:
    restart: always
    image: postgres:alpine
### Uncomment to enable DB persistance
    volumes:
      - ./postgres:/var/lib/postgresql/data

  redis:
    restart: always
    image: redis:alpine
### Uncomment to enable REDIS persistance
    volumes:
      - ./redis:/data
```

ここでビルドを実行。

```shell-session:Terminal
$ docker-compose build
```

続いて secret を 3 つ作成。一個目の時だけ色々走るので時間が掛かる。

```shell-session:Terminal
$ docker-compose run --rm web rake secret
(なんやかんや走って…)
019ab2fba0145 ... 8ab0e3
$ docker-compose run --rm web rake secret
a039b12ce89fa ... 84ffde 
$ docker-compose run --rm web rake secret
3c439ab43821d ... f8b201
```

上記の secret を基に、.env.production への設定を実施。
基本的には公式手順のまんま。唯一違うのは何となく PostgreSQL の DB にパスワードを付けたみたことくらい (意味あるのかなこれ？)。

```sh:.env.production
# Service dependencies
DB_PASS=(my-postgres-password)

# Federation
LOCAL_DOMAIN=mstdn.yamachan.org
LOCAL_HTTPS=true

# Application secrets
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
PAPERCLIP_SECRET=019ab2fba0145 ... 8ab0e3
SECRET_KEY_BASE=a039b12ce89fa ... 84ffde
OTP_SECRET=3c439ab43821d ... f8b201

# Optionally change default language
DEFAULT_LOCALE=ja

# E-mail configuration
# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
# If you want to use an SMTP server without authentication (e.g local Postfix relay)
# then set SMTP_AUTH_METHOD to 'none' and leave SMTP_LOGIN and SMTP_PASSWORD blank
SMTP_SERVER=(my-smtp-server)
SMTP_PORT=587
SMTP_LOGIN=(my-smtp-account)
SMTP_PASSWORD=(my-smtp-password)
SMTP_FROM_ADDRESS=(my-admin-mailaddr)
```

PostgreSQL のパスワードをコンテナ側で設定します。

```shell-session:Terminal
$ docker exec -it mastodon_db_1 /bin/bash
bash-4.3# su - postgres
(container hostname):~$ psql
postgres=# \du
postgres=# alter role postgres with password '(my-postgres-password)';
postgres=# \q
(container hostname):~$ exit
bash-4.3# exit
```

ここまで来たら、データベースのマイグレーションと、関連ファイルの事前準備をします。

```shell-session:Terminal
$ docker-compose run --rm web rails db:migrate
$ docker-compose run --rm web rails assets:precompile
```

### Apache でリバースプロキシを設定する

さてここまで来たら起動！・・・の前に。リバースプロキシを設定しないとまともに動かないので設定します。

まず、名前ベースのバーチャルホストにするために ssl.conf 内の `## SSL Virtual Host Context` 以下を削除しました。

```apache:/etc/httpd/conf.d/ssl.conf
##
## SSL Virtual Host Context
##
(これ以降をまるっと削除)
```

次に、conf.d 内に以下のようなバーチャルホスト定義用の設定ファイル virtualhost.conf を追加しました。
長いので分けて掲載します。

#### 従来サービスの http://～ 側

必要最小限の設定だけしておきます (ほとんどの項目は httpd.conf でグローバルに設定しているため)。

```apache:/etc/httpd/conf.d/virtualhost.conf(1/4)
# Name-based Virtual Host Settings

#### tokyo.yamachan.org:80 ####
<VirtualHost *:80>
  # ServerName and DocumentRoot are minimally required
  ServerName tokyo.yamachan.org
  DocumentRoot /var/www/html
</VirtualHost>
```

#### 従来サービスの https://～ 側

もともと ssl.conf に記載されていた内容をほぼコピペ。

```apache:/etc/httpd/conf.d/virtualhost.conf(2/4)
#### tokyo.yamachan.org:443 ####
<VirtualHost *:443>
  # Copy from ssl.conf
  ServerName tokyo.yamachan.org
  DocumentRoot /var/www/html

  ErrorLog logs/ssl_error_log
  TransferLog logs/ssl_access_log
  LogLevel warn

  SSLEngine on
  SSLProtocol all -SSLv2 -SSLv3
  SSLCipherSuite HIGH:MEDIUM:!aNULL:!MD5:!SEED:!IDEA

  SSLCertificateKeyFile /etc/letsencrypt/live/tokyo.yamachan.org/privkey.pem
  SSLCertificateFile /etc/letsencrypt/live/tokyo.yamachan.org/cert.pem
  SSLCertificateChainFile /etc/letsencrypt/live/tokyo.yamachan.org/chain.pem

  <Files ~ "\.(cgi|shtml|phtml|php3?)$">
    SSLOptions +StdEnvVars
  </Files>
  <Directory "/var/www/cgi-bin">
    SSLOptions +StdEnvVars
  </Directory>

  BrowserMatch "MSIE [2-5]" \
    nokeepalive ssl-unclean-shutdown \
    downgrade-1.0 force-response-1.0

  CustomLog logs/ssl_request_log \
    "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b"
</VirtualHost>
```

#### Mastodon 環境の http://～ 側

基本的には https:// にリダイレクトしたいので RewriteEngine を使用して飛ばしちゃいますが、全て飛ばすと今度は Let's Encrypt が動作しなくなってしまいます。
そこで、Let's Encrypt が用いる `/.well-known/acme-challenge` 以下だけは Rewrite しないように追加ルールを入れました。

```apache:/etc/httpd/conf.d/virtualhost.conf(3/4)
#### mstdn.yamachan.org:80 ####
<VirtualHost *:80>
  # ServerName and DocumentRoot are minimally required
  ServerName mstdn.yamachan.org
  DocumentRoot /var/www/html/mstdn

  RewriteEngine on
  # for Let's Encrypt
  RewriteRule "^/.well-known/acme-challenge" "-" [END]
  RewriteRule "^/(.*)" "https://mstdn.yamachan.org/$1"

  ErrorLog "logs/mstdn_error_log"
  LogLevel warn
  <IfModule log_config_module>
    LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
    LogFormat "%h %l %u %t \"%r\" %>s %b" common
    <IfModule logio_module>
      LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %I %O" combinedio
    </IfModule>
    CustomLog "logs/mstdn_access_log" combined
  </IfModule>
</VirtualHost>
```

#### Mastodon 環境の https://～ 側

Streaming API は WebSocket を使用しているので、明示的に ws://～ に飛ばします (要 mod_proxy_wstunnel)。
それ以外はフツーにリバースプロキシとして設定。
設定値は [公式の Nginx の設定](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Production-guide.md) に倣っています。Streaming API のバックエンドが *localhost:4000* なのに対してそれ以外は *127.0.0.1:3000* なのは何か違うんですかね…？

```apache:/etc/httpd/conf.d/virtualhost.conf(4/4)
#### mstdn.yamachan.org:443 ####
<VirtualHost *:443>
  # Copy from ssl.conf
  ServerName mstdn.yamachan.org
  DocumentRoot /var/www/html/mstdn

  # for Let's Encrypt
  #  Let's Encrypt only use non-secured http while certification,
  #  so this setting is not needed.
  # ProxyPass /.well-known !
  ProxyPass /api/v1/streaming ws://localhost:4000
  ProxyPass / http://127.0.0.1:3000/
  ProxyPassReverse /api/v1/streaming ws://localhost:4000
  ProxyPassReverse / http://mstdn.yamachan.org/

  ErrorLog logs/mstdn_ssl_error_log
  TransferLog logs/mstdn_ssl_access_log
  LogLevel warn

  SSLEngine on
  SSLProtocol all -SSLv2 -SSLv3
  SSLCipherSuite HIGH:MEDIUM:!aNULL:!MD5:!SEED:!IDEA

  SSLCertificateKeyFile /etc/letsencrypt/live/mstdn.yamachan.org/privkey.pem
  SSLCertificateFile /etc/letsencrypt/live/mstdn.yamachan.org/cert.pem
  SSLCertificateChainFile /etc/letsencrypt/live/mstdn.yamachan.org/chain.pem

  <Files ~ "\.(cgi|shtml|phtml|php3?)$">
    SSLOptions +StdEnvVars
  </Files>
  <Directory "/var/www/cgi-bin">
    SSLOptions +StdEnvVars
  </Directory>

  # tell frontend protocol to backend
  RequestHeader set X-Forwarded-Proto "https"

  BrowserMatch "MSIE [2-5]" \
    nokeepalive ssl-unclean-shutdown \
    downgrade-1.0 force-response-1.0

  CustomLog logs/mstdn_ssl_request_log \
    "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b"
</VirtualHost>
```

## Firewall に穴をあける

Apache のプロキシ設定を済ませても、これだけだと Firewall によってガードされてしまいます。
とりあえず docker の仮想ネットワークを処理対象に追加しつつ、port 3000/tcp, 4000/tcp を開放。
ゾーンの概念を使うとか、もっといい方法がありそうだけどとりあえずこれでいいかなぁ…。

```shell-session:Terminal
# firewall-cmd --permanent --add-interface=docker0
# firewall-cmd --permanent --add-port=3000/tcp
# firewall-cmd --permanent --add-port=4000/tcp
```

## さあ、動かしてみよう！

```shell-session:Terminal
$ docker-compose up -d
# systemctl reload httpd
```

名前ベースで (今回の場合だと https://mstdn.yamachan.org として) アクセス出来たら OK！
とりあえず自分のアカウントを作ってしまいましょう。

### アカウントに管理権限を与える

統計情報の確認やサーバ全体の設定変更、ユーザの追放・他インスタンスの拒否などをするための権限を特定のアカウントに与えます。

```shell-session:Terminal
$ docker-compose exec web bundle exec rails mastodon:make_admin USERNAME=(your account)
```

これでひとまず使えるはず。
Daily で動かすべきバッチ処理やらはテキトーに設定しましょー。

## 補足 : SELinux 環境下でもちゃんと動くようにする

試しに動かすだけならともかく、公開するつもりなら SELinux は有効化したいですよね。
基本的には、`/var/log/audit/audit.log` とにらめっこしながら **denied** と怒られている行を片っ端から潰していくのでいいはず…。

### httpd

```:audit.log
type=AVC msg=audit(**********.***:******): avc:
  denied  { name_connect } for  pid=***** comm="httpd"
  dest=3000
  scontext=system_u:system_r:httpd_t:s0
  tcontext=system_u:object_r:ntop_port_t:s0
  tclass=tcp_socket
```

"httpd" (Apache) による port 3000 へのアクセスが拒否されている。
まずは httpd によるネットワークリレーを有効にする。

```shell-session:Terminal
# setsebool -P httpd_can_network_relay on
```

更に、port 3000 や 4000 への通信をプロキシで用いるポート扱いにしてしまう。

```shell-session:Terminal
# semanage port -a -t http_cache_port_t -p tcp 3000
# semanage port -a -t http_cache_port_t -p tcp 4000
```

### redis, postgres

```:audit.log
type=AVC msg=audit(**********.***:******): avc:
  denied  { remove_name } for  pid=***** comm="redis-server"
  name="********" dev="sda*" ino=*****
  scontext=system_u:system_r:svirt_lxc_net_t:s0:c****,c****
  tcontext=system_u:object_r:user_home_t:s0
  tclass=dir
```

```:audit.log
type=AVC msg=audit(**********.***:******): avc:
  denied  { read write } for  pid=***** comm="postgres"
  name="********" dev="sda*" ino=*****
  scontext=system_u:system_r:svirt_lxc_net_t:s0:c****,c****
  tcontext=system_u:object_r:user_home_t:s0
  tclass=file
```

"redis-server" "postgres" によるディレクトリ・ファイルへのアクセスが拒否されている。
Mastodon の導入先のディレクトリのコンテキストを `svirt_sandbox_file_t` に設定すればよい。
(仮想マシンのサンドボックスである旨を明示することで、読み書きを許可することになる？)

```shell-session:Terminal
# chcon -Rt svirt_sandbox_file_t postgres
# chcon -Rt svirt_sandbox_file_t redis
# chcon -Rt svirt_sandbox_file_t public/assets
# chcon -Rt svirt_sandbox_file_t public/system
```

### 最後に

祈りながら

```shell-session:Terminal
# setenforce 1
```

しましょう。