一般ユーザ権限で特権ポートを bind したい

Web サーバ H2O
root 権限の無い一般ユーザで運用したいと考えました。

通常、root 権限で h2o を実行すると、
TCP port 80 (HTTP) と port 443 (HTTPS) を bind して、
その後 nobody ユーザに setuid で切り替えます。
nobody ユーザはシェルにログインできない一般ユーザなので、安全。

なぜ先に TCP ポートを bind するのかというと、
1024 番未満のポートは “privileged port”
well-known port numbers, 予約ポート, 特権ポート)
と呼ばれるポートなので一般ユーザ権限では触れないからです。
HTTP プロキシとかでよく使われる port 8080 なら root 権限は不要なのですが。

ところで、実は Seaoak はこの nobody ユーザが好きではありません。
たとえば、あるファイル/ディレクトリについて、
CGI プログラムとふつうの一般ユーザの両方が書き込めるようにすると、
chmod o+w が必要になるのが気持ち悪い。
あと、perlbrew とか nvm とかも利用したいです。
「いまさら CGI かよ」という意見もあるとは思いますが……。

もちろん、ふつうの一般ユーザ権限で Web サーバを運用すると、
万一、プロセスを乗っ取られた時に、被害が大きくなります。
sudo できるユーザだったりしたら最悪です。

いちおうそういうリスクは認識した上で、トライ。

ちなみにサーバ環境は Ubuntu 16.04.1 LTS (Xenial Xerus) x86_64 です。

事前準備

ファイアウォールで標準のポートを開けておきます:

1
2
3
$ sudo ufw allow 80
$ sudo ufw allow 443
$ sudo ufw status

なお、いちおう UDP ポートも開けておきました。(後述)

案1: Linux Capabilities (setcap)

root 権限が無くても特権ポートにアクセスできるようにする仕組みがありました。

特定の実行ファイルに CAP_NET_BIND_SERVICE capability を設定すると、
そのプログラムは特権ポートにアクセスできるらしいです。

さっそく試してみます:

1
2
$ sudo setcap CAP_NET_BIND_SERVICE+ep /usr/local/bin/h2o
$ sudo getcap /usr/local/bin/h2o

これで h2o -m worker はうまく動きました。

しかし、h2o -m daemonh2o -m master はダメでした。
/usr/local/bin/h2o から呼ばれているシェルスクリプト
/usr/local/share/h2o/start_server の中で、特権ポートが
open できないと言われてしまいます。
capability に +i (inheritable) を加えて
setcap CAP_NET_BIND_SERVICE+eip と指定してもダメでした。
capability がうまく継承されないようです。

とりあえず、元に戻します:

1
2
$ sudo setcap -r /usr/local/bin/h2o
$ sudo getcap /usr/local/bin/h2o

案2: ポートのリダイレクト

ファイアウォール (iptables) で特権ポートから非特権ポートにリダイレクトして、
Web サーバはその非特権ポートを bind する、という手段がありました。

2番目の記事の「Ubuntu*/Debian systems can use ufw as a firewall for port redirection:」を参考にしました。
また、3番目の記事の「成功その3」を採用させていただきました。

まず、リダイレクト先のポート番号は以下を参考に適当に選びます:

本来はエフェメラルポートを使うべきなのかもしれませんが、なんとなく気分で、誰も使っていなさそうなポート番号を適当に選びました:

port 5210 : redirect from  80 (HTTP)
port 5211 : redirect from 443 (HTTPS)
port 5212 : always drop

次に、ufw の設定ファイル /etc/ufw/before.rules および /etc/ufw/before6.rules
に以下の行を追加します:

1
2
3
4
5
6
7
8
9
10
11
*nat
:PREROUTING ACCEPT [0:0]
-A PREROUTING -p tcp --dport 5210 -j REDIRECT --to-port 5212
-A PREROUTING -p udp --dport 5210 -j REDIRECT --to-port 5212
-A PREROUTING -p tcp --dport 5211 -j REDIRECT --to-port 5212
-A PREROUTING -p udp --dport 5211 -j REDIRECT --to-port 5212
-A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 5210
-A PREROUTING -p udp --dport 80 -j REDIRECT --to-port 5210
-A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5211
-A PREROUTING -p udp --dport 443 -j REDIRECT --to-port 5211
COMMIT

Seaoak はとりあえず設定ファイルの先頭部分(コメント行
# Don't delete these required lines, otherwise there will be errors
の前)に上記の行を挿入しました。

具体的な手順としては、次のようになります:

1
2
3
4
5
6
7
$ sudo vi /etc/ufw/before.rules
$ sudo vi /etc/ufw/before6.rules
$ sudo ufw allow 5210
$ sudo ufw allow 5211
$ sudo ufw deny 5212
$ sudo ufw disable
$ sudo ufw enable

ufw deny 5212 は念のためです。
なお、今回試した限りではサーバのリブートは不要でした。

次に、H2O の設定ファイル h2o.conf を変更します。
listen: ディレクティブで指定するポート番号を “80” から “5210” に、
“443” から “5211” に、それぞれ変更します。
ここで、hosts: ディレクティブに書く host:port:port 部分は
:80 とか :443 とかのまま変更してはいけません。

なぜなら、クライアントがリクエストした URL は、
あくまでも port 80 / 443 に対するものだからです。
ここを :5210 とか :5211 とかに変えてしまうと、
クライアントがリクエストした URL (の host:port 部分)
にマッチしなくなってしまいます。

ちなみに、困ったことに、h2o.conflisten: ディレクティブを
global-level に記述している場合、上記のようなミスをすると、
無条件に hosts: ディレクティブの先頭のエントリにマッチしたものとして扱われてしまいます。
公式ドキュメントにも明記されています。
エラーにはなりません。
この罠に Seaoak はハマりました。
今後も同じようなミスをする可能性があるので、対策として、
Seaoak の h2o.conf では hosts: ディレクティブの先頭に
「番兵」(ダミー)を置くことにしました:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
hosts:
"sentinel.example.com":
paths:
"/":
file.dir: /dev/null
access-log: "| rotatelogs -l access-log.sentinel.%Y%m%d 86400"
"seaoak.jp:443":
paths:
"/":
file.dir: /path/to/doc-root
access-log: "| rotatelogs -l access-log.root.%Y%m%d 86400"
"seaoak.jp:80":
paths:
"/":
redirect:
url: "https://seaoak.jp/"
status: 301
access-log: "| rotatelogs -l access-log.root-nossl.%Y%m%d 86400"

ちなみに、file.dir: /dev/null とすると “404 Not Found” が返ります。

閑話休題。

以上の状態で h2o を動かしてみると、h2o -m workerh2o -m daemon
h2o -m master も動きました。特にエラーメッセージも出ません。

ブラウザで http://seaoak.jp とか https://seaoak.jp
とかにアクセスしてみると、問題なくアクセスできました。
また、http://seaoak.jp:5210 とか https://seaoak.jp:5211
とかにアクセスしてみると接続エラーになります。
すばらしい!!

また、別に借りているサーバから IPv6 でアクセスすると無事成功しました。

カンペキです。

案3: Linux Capabilities (Ambient capabilities)

案1で試した setcap による capabilities 機能は、正確には
“File capabilities” と呼ぶものらしく、それとは別に、
“Ambient capabilities” というものがあるらしいです。

Seaoak は試していませんが、もしかするとうまい手があるかもしれません。

ご参考まで。

How about UDP ports ?

とりあえず UDP のポート 80 / 443 も開けておいたほうが良さそうです。

いちおう H2O でも QUIC を実装しようという Issue が上がっています。
https://github.com/h2o/h2o/issues/275

オチ?

h2o プロセスがダウンしたときに自動的に再実行したい、とか言うと、
結局 systemd のお世話になるので、上記の話はすべて無駄になりそうです。