14 Июль 2017

Переезд блога на HTTP/2 сервер H2O

Blog moving on H2O HTTP/2 server

Title: Переезд блога на HTTP/2 сервер H2O Content: Вероятно, постоянные читатели блога kostikov.co заметили, что он, как, впрочем, и ряд других поддерживаемых автором HTTP-серверов, работают на базе Lighttpd. Действительно, этот быстрый и лёгкий веб-сервер снискал широкую популярность и был широко распространён в качестве лучшей альтернативы стандартному Apache. Однако, с течением времени и неизбежным прогрессом в сетевых технологиях разработчики Lighttpd заметно отстали в их поддержке, а конкуренты, в лице, главным образом, Nginx, за тот же интервал существенно нарастил функционал и стал куда более распространённым решением.

Наиболее существенным недостатком Lighttpd представляется отсутствие в настоящее время поддержки им современной версии протокола HTTP/2, которая имеет большое количество преимуществ перед предыдущими версиями. Также минусом этого веб-сервера является отсутствие поддержки протокола Online Certificate Status Protocol (OCSP), который начинает приобретать важное значение в свете борьбы со злоупотреблениями при выпуске SSL-сертификатов.

В связи с вышеизложенным, после изучения возможных альтернатив, автор принял решение о переносе блога на HTTP/2 сервер H2O, которому ранее была посвещена обзорная статья, как на наиболее прогрессивный и перспективный в обозримом будущем вариант.

1. Подготовка

Блог kostikov.co работает на системе управления контентом (CMS) [Bludit](https://www.bludit.com, которая написана) PHP и организована по принципу flat file, то есть использует исключительно файловую систему для хранения своих данных, на операционной системе FreeBSD.

root@beta:~ # uname -v
FreeBSD 11.0-RELEASE-p9 #0: Tue Apr 11 08:48:40 UTC 2017     root@amd64-builder.daemonology.net:/usr/obj/usr/src/sys/GENERIC

Обработка производится PHP последней актуальной версии посредством службы PHP-FPM или PHP FastCGI Process Manager, которая запущена в качестве отдельного сервиса и отвечает на запросы по стандартному порту 9000 на локальном интерфейсе.

root@beta:~ # php -v
PHP 7.1.7 (cli) (built: Jul  8 2017 12:57:01) ( NTS )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2017 Zend Technologies
root@beta:~ # grep php /etc/rc.conf
php_fpm_enable="YES"
root@beta:~ # ps -aux | grep php-fpm
root     10876   0,0  0,4  153300  18640  -  Ss   22:34        0:02,21 php-fpm: master process (/usr/local/etc/php-fpm.conf) (php-fpm)
www      10878   0,0  0,6  184380  24124  -  I    22:34        0:10,73 php-fpm: pool www (php-fpm)
www      16019   0,0  0,6  188836  26900  -  I    00:48        0:19,17 php-fpm: pool www (php-fpm)
www      16070   0,0  0,6  185172  25596  -  I    00:52        0:09,38 php-fpm: pool www (php-fpm)
root     28217   0,0  0,1   14852   2568  0  S+   11:58        0:00,01 grep php-fpm
root@beta:~ # sockstat -46 | grep 9000
www      php-fpm    16070 0  tcp4   127.0.0.1:9000        *:*
www      php-fpm    16019 0  tcp4   127.0.0.1:9000        *:*
www      php-fpm    10878 0  tcp4   127.0.0.1:9000        *:*
root     php-fpm    10876 7  tcp4   127.0.0.1:9000        *:*

Кроме того, сайт работает по защищённому протоколу HTTPS используя сертификаты выданные Let's Encrypt. Для дополнительной безопасности работы с сертификатами применяется технология HTTP Public Key Pining (HPKP), которой автор ранее уделил внимания в отдельной статье. Напомню, что HPKP реализуется посредством включения особого заголовка в HTTP-ответ сервера, содержимое которого генерируется специальным скриптом lerenew.sh автоматического обновления сертификатов Let's Encrypt. Ввиду того, что формат конфигурационного файла H2O, разумеется, отличается от ранее используемого в Lighttpd, потребуется модификация и скрипта обновления.

Кроме того, используется и набор других заголовков безопасности HTTP (см. "Заголовки HTTP и безопасность web-сервера"), что также требуется учесть при запуске сайта на базе нового веб-сервера.

2. Конфигурирование

Конфигурационный файл H2O h2o.conf расположен по стандартному пути /usr/local/etc/h2o. Модифицируем его для нужд работы CMS Bludit с учётом упомянутых в предыдущем разделе особенностей.

root@beta:/usr/local/etc/h2o # cd /usr/local/etc/h2o/
root@beta:/usr/local/etc/h2o # cat h2o.conf
# see https://h2o.examp1e.net/ for detailed documentation
# see h2o --help for command-line options and settings

# v.20170714 (c)2017 by Max Kostikov http://kostikov.co e-mail: max@kostikov.co

user: www
pid-file: /var/run/h2o.pid
access-log: /var/log/h2o/h2o-access.log
error-log: /var/log/h2o/h2o-error.log

expires: off
file.dirlisting: off
file.send-compressed: on

file.index: [ 'index.html', 'index.php' ]
file.mime.addtypes:
    "application/x-font-ttf": ".ttf"
    "application/x-font-opentype": ".otf"

file.custom-handler:
    extension: ".php"
    fastcgi.connect:
        host: 127.0.0.1
        port: 9000
        type: tcp

listen:
    port: 80
listen:
    port: 443
    ssl: &default_ssl
        cipher-suite: ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS
        cipher-preference: server
        dh-file: /etc/ssl/dhparams.pem
        certificate-file: /usr/local/etc/letsencrypt/live/my.server/fullchain.cur.pem
        key-file: /usr/local/etc/letsencrypt/live/my.server/privkey.cur.pem
...
# --- kostikov.co
    "kostikov.co:80":
        listen:
            port: 80
        paths:
            "/":
                redirect:
                    status: 301
                    url: https://kostikov.co/
    "kostikov.co:443":
        <<: !file /usr/local/etc/h2o/hdr/kostikov.co.full.h2o
        listen:
            port: 443
            ssl:
                 <<: *default_ssl
                 certificate-file: /usr/local/etc/letsencrypt/live/kostikov.co/fullchain.cur.pem
                 key-file: /usr/local/etc/letsencrypt/live/kostikov.co/privkey.cur.pem
        paths:
            "/":
                mruby.handler: |
                    acl {
                        deny { path.match("^bl-content/(.*)\.txt$") }
                    }
                file.dir: /usr/local/www/kostikov.co
                redirect:
                    url: /index.php
                    internal: YES
                    status: 307
...

Как видно, в глобальной секции помимо типовых параметров через опцию file.custom-handler добавлен обработчик расширений .php, который обращается к сервису php-fpm. Также в дополнение к стандартным для H2O MIME типам через директиву file.mime.addtypes добавлены ещё два, которые использует Bludit в шрифтовых оформлениях своих тем.

В описании имени хоста kostikov.co при обращении по незащищённом протоколу HTTP командой redirect производится переадресация на защищённую версию сайта с кодом ответа 301 Moved Permanently. При работе по HTTPS, во-первых, директивой <<: !file из файла kostikov.co.full.h2o расположенного в специально созданном подкаталоге /usr/local/etc/h2o/hdr добавляются дополнительные заголовки безопасности. Его содержимое таково.

root@beta:/usr/local/etc/h2o # cat hdr/kostikov.co.full.h2o
header.add: "Content-Security-Policy: upgrade-insecure-requests; default-src 'self'; script-src 'unsafe-inline' 'unsafe-eval' https://www.google-analytics.com https://mc.yandex.ru https://static.addtoany.com https://*.disqus.com https://*.disquscdn.com 'self'; style-src 'unsafe-inline' https://fonts.googleapis.com https://static.addtoany.com https://*.disquscdn.com 'self'; font-src https://fonts.gstatic.com 'self'; connect-src https://mc.yandex.ru https://links.services.disqus.com 'self'; img-src https://cex.io https://www.google-analytics.com https://mc.yandex.ru https://static.addtoany.com data: https://*.disquscdn.com https://referrer.disqus.com https://affiliates.purevpn.com 'self'; frame-src https://static.addtoany.com https://disqus.com 'self'"
header.add: "Strict-Transport-Security: max-age=15768000; includeSubDomains; preload"
header.add: "X-Content-Type-Options: nosniff"
header.add: "X-XSS-Protection: 1; mode=block"
header.add: "X-Frame-Options: DENY"
header.add: "Public-Key-Pins: pin-sha256=\"m5meRZL/rKw5ItGkshSqj3gJDwl9ghvL2PZIkiEuV1s=\"; pin-sha256=\"dIQH62dSELdTCHaxFmNPhAlZGKlLtaf1B3X3q5SsNAU=\"; max-age=3456000; includeSubDomains"

Выбор единого файла для хранения HTTP-заголовков определён тем, что сайт работает исключительно по защищённому протоколу и нет нужды в разделении заголовков по критерию их применимости (или наоборот) в незащищённом режиме.

Далее, в опции mruby.handler через встроенную в H2O систему контроля доступа (ACL) производится ограничение прямого доступа к внутреннему представлению содержимого сайта. И, наконец, ещё один redirect с кодом ответа 307 Temporary Redirect перенаправляет все обращения к сайту на единый обработчик расположенный в файле index.php.

Также, как уже было упомянуто, нам потребуется при каждом обновлении SSL-сертификатов обновлять их цифровые отпечатки используемые для реализации HPKP, которые передаются в заголовке Public-Key-Pins. Модернизированный для этого скрипт lerenew.sh приобрёл следующий вид.

#!/bin/sh

# Update "Let's Encrypt" certificates,
# make cert+key joined files, HPKP hashes and TLSA records
# v.20170713 (c)2016-17 by Max Kostikov http://kostikov.co e-mail: max@kostikov.co
#
# cat /etc/crontab | grep lerenew
# 0 0 * * 1 root /usr/local/etc/letsencrypt/lerenew.sh >/dev/null 2>&1

lepath="/usr/local/etc/letsencrypt"
log="/var/log/letsencrypt/lerenew.log"
pins="/usr/local/etc/h2o/hdr"                          # pins for H2O
nsddir="/usr/local/etc/nsd"                             # path to NSD dir
tlsapref="_tlsa"                                        # prefix for TLSA records, use CNAME to point on
joincrt="privandcert"                                   # joined file name
curpref="cur"                                           # current set postfix

echo "`date '+%Y-%m-%d %H:%M:%S'` --- Starting SSL certs renew..." >>$log

certbot renew -n $* >>$log 2>&1

certs=`find ${lepath}/archive/*/cert*.pem -mtime -1h`

if [ "${certs}" ];
then
        for i in $certs;
        do
                dir=`dirname $i`                         # path to certs
                dom=`basename $dir`                     # domain name
                ind=`basename $i | tr -d '[:alpha:].'`  # latest cert index
                cur=`expr $ind - 1`                     # previous cert index

                # create new joined cert and symlink
                cat ${dir}/privkey${ind}.pem $i > ${dir}/${joincrt}${ind}.pem
                cd ${lepath}/live/${dom}
                ln -fs ../../archive/${dom}/${joincrt}${ind}.pem ${joincrt}.pem

                # create symlinks for previous certs set
                for j in `ls -d ../../archive/${dom}/*${cur}.*`;
                do
                        ln -fs $j `basename -s .pem $j | tr -d '[:digit:]'`.${curpref}.pem
                done

                # create cert pins
                # H2O
                conf="${pins}/${dom}.ssl.h2o"         # pin config name
                sed -i.bak '/^header.add: "Public-Key-Pins/d' $conf
                echo 'header.add: "Public-Key-Pins: pin-sha256=\"'"`openssl x509 -in cert.${curpref}.pem -pubkey -noout | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64`"'\"; pin-sha256=\"'"`openssl x509 -in cert.pem -pubkey -noout | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64`"'\"; max-age=3456000; includeSubDomains"' >> $conf
                cat ${pins}/hsts.h2o ${pins}/${dom}.h2o $conf 2>/dev/null > ${pins}/${dom}.full.h2o

                # update DNS zone if DNSSEC enabled
                zone="${nsddir}/zones/${dom}/${dom}.zone"
                if [ -f ${zone}.signed ];
                then
                        sed -i.bak "/TLSA/d" $zone
                        echo "${tlsapref}.${dom}.       IN TLSA 3 0 1   `openssl x509 -in cert.${curpref}.pem -outform DER | sha256`" >>$zone
                        echo "${tlsapref}.${dom}.       IN TLSA 3 0 1   `openssl x509 -in cert.pem -outform DER | sha256`" >>$zone
                        ${nsddir}/dnsnewserial.sh $zone
                        ${nsddir}/dnssignzone.sh $zone
                fi
        done

        # reload services
        nsd-control reload
        service h2o restart
        service dovecot restart
        service exim restart
fi

echo "`date '+%Y-%m-%d %H:%M:%S'` --- SSL certs renew done!" >>$log

В процессе его работы, во-первых, модифицируется промежуточный файл с заголовками исключительно для режима HTTPS с наименованием kostikov.co.ssl.h2o. Далее из него, а также двух других файлов — hsts.h2o, содержащего стандартное значение для заголовка Strict-Transport-Security и kostikov.co.h2o с заголовками, которые могут использоваться в обоих режимах обмена данным по HTTP, создаётся единый файл с полным набором HTTP-заголовков для защищённого режима kostikov.co.full.h2o, который и включается в конфигурацию H2O.

root@beta:/usr/local/etc/h2o # cat hdr/hsts.h2o
header.add: "Strict-Transport-Security: max-age=15768000; includeSubDomains; preload"
root@beta:/usr/local/etc/h2o # cat hdr/kostikov.co.h2o
header.add: "Content-Security-Policy: upgrade-insecure-requests; default-src 'self'; script-src 'unsafe-inline' 'unsafe-eval' https://www.google-analytics.com https://mc.yandex.ru https://static.addtoany.com https://*.disqus.com https://*.disquscdn.com 'self'; style-src 'unsafe-inline' https://fonts.googleapis.com https://static.addtoany.com https://*.disquscdn.com 'self'; font-src https://fonts.gstatic.com 'self'; connect-src https://mc.yandex.ru https://links.services.disqus.com 'self'; img-src https://cex.io https://www.google-analytics.com https://mc.yandex.ru https://static.addtoany.com data: https://*.disquscdn.com https://referrer.disqus.com https://affiliates.purevpn.com 'self'; frame-src https://static.addtoany.com https://disqus.com 'self'"
header.add: "X-Content-Type-Options: nosniff"
header.add: "X-XSS-Protection: 1; mode=block"
header.add: "X-Frame-Options: DENY"
root@beta:/usr/local/etc/h2o # cat hdr/kostikov.co.ssl.h2o
header.add: "Public-Key-Pins: pin-sha256=\"m5meRZL/rKw5ItGkshSqj3gJDwl9ghvL2PZIkiEuV1s=\"; pin-sha256=\"dIQH62dSELdTCHaxFmNPhAlZGKlLtaf1B3X3q5SsNAU=\"; max-age=3456000; includeSubDomains"

Такой подход по разбивке на несколько отдельных файлов является универсальным и позволяет включать различным сайтам, которые могут работать на том же веб-сервере, включать заголовки блоками в зависимости от режима и потребности.

3. Тестирование

Завершив конфигурирование можно запустить наш новый HTTP/2 сервер H2O, конечно не забыв предварительно остановить старый добрый Lighttpd и поправить содержимое файла /etc/rc.conf.

root@beta:~ # cat /etc/rc.conf
# -- sysinstall generated deltas -- # Wed Jan 12 19:24:42 2011
...
php_fpm_enable="YES"
h2o_enable="YES"
#lighttpd_enable="YES"
...
root@beta:~ # service lighttpd stop && service h2o start
Stopping lighttpd.
Waiting for PIDS: 30670.
Starting h2o.
start_server (pid:30687) starting now...

Проверить, что система работает должным образом можно из командной строки используя, к примеру, утилиту cURL.

root@beta:~ # curl -IL http://kostikov.co
HTTP/1.1 301 Redirected
Date: Fri, 14 Jul 2017 14:54:53 GMT
Connection: keep-alive
Server: h2o/2.2.2
location: https://kostikov.co/
content-type: text/html; charset=utf-8

HTTP/2 200
server: h2o/2.2.2
date: Fri, 14 Jul 2017 14:54:53 GMT
content-security-policy: upgrade-insecure-requests; default-src 'self'; script-src 'unsafe-inline' 'unsafe-eval' https://www.google-analytics.com https://mc.yandex.ru https://static.addtoany.com https://*.disqus.com https://*.disquscdn.com 'self'; style-src 'unsafe-inline' https://fonts.googleapis.com https://static.addtoany.com https://*.disquscdn.com 'self'; font-src https://fonts.gstatic.com 'self'; connect-src https://mc.yandex.ru https://links.services.disqus.com 'self'; img-src https://cex.io https://www.google-analytics.com https://mc.yandex.ru https://static.addtoany.com data: https://*.disquscdn.com https://referrer.disqus.com https://affiliates.purevpn.com 'self'; frame-src https://static.addtoany.com https://disqus.com 'self'
strict-transport-security: max-age=15768000; includeSubDomains; preload
x-content-type-options: nosniff
x-xss-protection: 1; mode=block
x-frame-options: DENY
public-key-pins: pin-sha256="m5meRZL/rKw5ItGkshSqj3gJDwl9ghvL2PZIkiEuV1s="; pin-sha256="dIQH62dSELdTCHaxFmNPhAlZGKlLtaf1B3X3q5SsNAU="; max-age=3456000; includeSubDomains
x-powered-by: PHP/7.1.7
set-cookie: Bludit-KEY=2c11b5d36c24f3ab375cff926dc7a2af; path=/; HttpOnly
expires: Thu, 19 Nov 1981 08:52:00 GMT
cache-control: no-store, no-cache, must-revalidate
pragma: no-cache
content-type: text/html; charset=UTF-8

Как можно видеть, при начальном обращении по незащищённому протоколу HTTP/1.1 произошла переадресация на защищённое соединение уже по протоколу HTTP/2. При этом были добавлены необходимые HTTP-заголовки.

4. PROFIT!

Статья была полезной? Тогда прошу не стесняться и деньгами или биткоинами.


bludit  dev  H2O  HTTP  letsencrypt  lighttpd  shell  SSL