Сегодня я решил снова завести себе блог. В качестве платформы был взят org-page — статический генератор, основанный на Org mode.

Здесь я опишу процесс создания блога с помощью org-page (который был не совсем радужным) и проблемы, с которыми я столкнулся.

Первый блин

Сначала я установил org-page с помощью use-package:

  (use-package org-page
    :ensure t
    :config
    (setf op/site-preview-directory "/tmp/org-page-preview"
          op/repository-directory "~/src/maximov.space"))

Следующий шаг — создание нового репозитория, в котором будет храниться код блога, с помощью команды op/new-repository. Тут меня поджидала неприятность: команда завершилась с ошибкой, выдав сообщение о том, что не получилось создать git-репозиторий. Однако быстрая проверка показала, что нужная директория всё-таки появилась, и в ней был инициализирован пустой репозиторий.

Решив выяснить, в чем же дело, я решил залезть в исходники. Как оказалось, виновата была функция op/git-init-repo:

  (defun op/git-init-repo (repo-dir)
    (unless (file-directory-p repo-dir)
      (mkdir repo-dir t))
    (unless (string-prefix-p "Initialized empty Git repository"
                             (op/shell-command repo-dir
                                               "git init" nil))
      (error "Fatal: Failed to initialize new git repository '%s'."
             repo-dir)))

Проблема оказалась в том, что в локали ru_RU.UTF-8 сообщения git выдавал на русском языке. Т.е. там, где функция op/git-init-repo ожидала встретить строку Initialized empty Git repository, она получала Инициализирован пустой репозиторий Git.

Я открыл баг в репозитории org-page (EDIT закрыт).

Исправляем ошибки

Чтобы не ждать, пока баг исправят, я решил сделать это сам, и переписал функции, работающие с git, с помощью библиотеки git.el, также сделал PR (EDIT принят) в репозиторий org-page. Вышеупомянутая функция op/git-init-repo стала выглядеть так:

  (defun op/git-init-repo (repo-dir)
    (unless (file-directory-p repo-dir)
      (mkdir repo-dir t))
    (git-init repo-dir))

Весь результат можно увидеть здесь.

Настройка блога

Теперь, когда баг исправлен, можно настроить переменные, используемые при генерации блога. Настройка выполняется в :config-секции пакета use-package:

  (use-package org-page
    :load-path "lib/org-page"
    :config
    (setf op/site-preview-directory "/tmp/org-page-preview"
          op/repository-directory "~/src/maximov.space"

          op/site-domain "https://maximov.space"
          op/site-main-title "Untitled"
          op/site-sub-title "Emacs, Programming, an Anything"

          op/personal-disqus-shortname "smaximov"
          op/personal-github-link "https://github.com/smaximov"
          op/personal-google-analytics-id "UA-74709646-1"))

Настройка VPS

Я решил хостить блог на VPS с CentOS 7.

Для начала нам нужен пользователь, под которым мы будем работать.

Создание нового пользователя

Добавим пользователя:

  $ useradd nameless
  $ passwd nameless

Создадим группу sudoers, добавляем туда нашего пользователя:

  $ groupadd -r sudoers
  $ usermod -aG sudoers nameless

Чтобы дать возможность sudoers использовать sudo, воспольуемся EDITOR=emacs visudo.

Завершим SSH-сессию и импортируем ssh-ключ:

  [desktop] $ ssh-copy-id nameless@maximov.space

Залогинимся на maximov.space снова, теперь ssh-клиент должен использовать ключ для логина. Изменим параметр PasswordAuthentication на no в файле /etc/ssh/sshd_config, чтобы запретить вход по паролю. После этого нужно перезапустить sshd:

  $ sudo systemctl restart sshd

Теперь можно работать под новым пользователем.

Установка пакетов

Нам понадобятся следующие пакеты:

  • git для работы с git-репозиториями;
  • nginx в качестве сервера нашего блога;
  • letsencrypt для генерации SSL-сертификатов.

Добавим репозиторий epel и установим нужные пакеты:

  $ sudo yum install epel-release
  $ sudo yum update
  $ sudo yum install nginx git letsencrypt

Настройка деплоя

Для начала создадим директории, которые нам понадобятся в дальнейшем:

  $ sudo install -o nameless -g nameless -m 755 \
    -d /var/www/maximov.space
  $ sudo install -o nameless -g nameless -m 755 \
    -d /var/repos/maximov.space.git
  $ sudo install -o nameless -g nameless -m 755 \
    -d /var/www/common/letsencrypt

В директории /var/www/maximov.space будет храниться блог, в /var/repos/maximov.space.gitgit-репозиторий; назначение директории /var/www/common/letsencrypt будет рассмотрено в разделе Сертификаты.

Создадим в директории /var/repos/maximov.space.git пустой репозиторий:

  $ git init --bare /var/repos/maximov.space.git

Мы создаём bare («голый» или «пустой») репозиторий, он не содержит рабочего дерева, в нём хранятся только служебные файлы (содержимое .git). Это сделано для того, чтобы можно было пушить в него через ssh, т.к. обновление текущей ветки для не-bare репозиториев запрещено.

Так как bare репозиторий не хранит рабочего дерева, нужно определить hook, который будет выполняться после каждого пуша. Создадим в директории hooks файл post-receive:

  #!/bin/sh
  git --work-tree=/var/www/maximov.space \
      --git-dir=/var/repos/maximov.space.git \
      checkout -f

Сделаем его исполняемым:

  chmod +x /var/repos/maximov.space.git/hooks/post-receive

Данный хук будет разворачивать рабочее дерево в /var/www/maximov.space каждый раз, когда был выполнен push.

Укажем этот репозиторий в качестве remote для локального репозитория:

  $ git remote add maximov.space \
    ssh://maximov.space/var/repos/maximov.space.git

Деплой осуществляется командой

  $ git push maximov.space master

Также будем бекапить ветку source в Gitlab:

  $ git remote add origin \
    git@gitlab.com:smaximov/maximov.space.git

SELinux

Для того, чтобы веб-сервер мог отдавать статические файлы (точнее, чтобы SELinux разрешил серверу отдавать эти файлы), необходимо, чтобы они были помечены типом SELinux httpd_sys_content_t. Файлы, помеченные этим типом, доступны в режиме чтения для веб-сервера и скриптов, которые он запускает.

Выполнив semanage fcontext -l | grep httpd_sys_content_t, можно убедиться, что по умолчанию данный тип применяется для всех файлов, которые соответствуют регулярному выражению /var/www(/.*)?.

Чтобы восстановить контекст SELinux для директории, которая будет раздаваться веб-сервером, выполним команду:

  $ restorecon -R /var/www/

Настройка Nginx

Для начала запустим Nginx, чтобы убедиться, что он работает:

  $ sudo systemctl start nginx

Если всё прошло успешно, то по адресу http://maximov.space должна быть дефолтная страница Nginx:

images/nginx-default.png

Теперь приступим к генерации SSL-сертификата для наших доменов.

Сертификаты

Воспользуемся клиентом letsencrypt для генерации SSL-сертификата для доменов maximov.space и www.maximov.space. Перед тем, как сгенерировать сертификат для домена, letsencrypt должен удостовериться, что домен принадлежит именно нам.

Такая проверка осуществляется с помощью одного из поддерживаемых плагинов. Один из самых простых — это плагин standalone, который запускает локальный веб-сервер для коммуникации с проверяющим сервером Let's Encrypt. Сгенерировать сертификат для наших доменов с использованием этого плагина можно было бы так:

  $ sudo letsencrypt certonly --standalone \
    --email s.b.maximov@gmail.com --agree-tos \
    --domains maximov.space,www.maximov.space

Сгенерированные сертификаты сохранены в директории /etc/letsencrypt/live/maximov.space/ вместе с параметрами генерации, которые в дальнейшем будут использоваться для обновления сертификата.

Первоначально я и воспользовался данным методом, однако, несмотря на всю его простоту, он имеет серьёзный недостаток: для работы плагина standalone необходимо останавливать уже запущенные веб-серверы, такие как nginx, что, в свою очередь, создаст проблемы при обновлении сертификата. Поэтому вместо этого мы воспользуемся плагином webroot.

Этот плагин требует наличие существующего веб-сервера, способного отдавать контент из директории web root, которая должна быть доступна для модификации пользователю, запускающему letsencrypt. Ранее мы создали директорию /var/www/common/letsencrypt (${webroot-path}), которая и представляют web root для нашего сервера.

Плагин работает путём создания временного файла в ${webroot-path}/.well-known/acme-challenge. Затем удостоверяющий сервер Let's Encrypt делает HTTP-запрос на получение этого файла, который проверяет, что DNS-запись для наших доменов резолвится на запущенный веб-сервер. При этом в логах можно увидеть примерно такой запрос:

  "GET /.well-known/acme-challenge/${some-random-string} HTTP/1.1"

Удалим дефолтный сервер из /etc/nginx/nginx.conf, создадим /etc/nginx/conf.d/maximov.space.conf.

  server {
      listen 80;

      server_name www.maximov.space maximov.space;

      location /.well-known/acme-challenge/ {
          default_type "text/plain";
          root /var/www/common/letsencrypt;
      }

      location / {
          return 301 https://maximov.space$request_uri;
      }
  }

Данный сервер обрабатывает HTTP-запросы, отдавая по путям, начинающимся с /.well/known/acme-challenge/, файлы из /var/www/common/letsencrypt (${webroot-path}). Для всех остальных путей производится перенаправление на https://maximov.space$request_uri.

Перезагрузим конфигурацию nginx, чтобы внесённые изменения вступили в силу ($ sudo systemctl reload nginx).

Теперь создадим скрипт, который будет использовать этот плагин. Для удобства мы воспользуемся файлом конфигурации вместо опций командной строки. Создадим /etc/letsencrypt/maximov.space.ini:

  rsa-key-size=4096
  server=https://acme-v01.api.letsencrypt.org/directory
  email=s.b.maximov@gmail.com
  domains=maximov.space,www.maximov.space
  agree-tos=True
  force-renew=True

Рассмотрим использованные опции:

rsa-key-size
размер ключа.
server
адрес удостоверяющего сервера Let's Encrypt. Значение по умолчанию — https://acme-v01.api.letsencrypt.org/directory. Для тестирования этот параметр можно изменить на https://acme-staging.api.letsencrypt.org/directory либо указать в качестве альтернативы staging=True, также при этом может понадобиться указать параметр break-my-certs=True, который позволит заменить валидный сертификат тестовым.
email
почтовый адрес, используемый при регистрации.
domains
список доменов, на которые распространяется сертификат.
agree-tos
согласиться с пользовательским соглашением.
force-renew
обновить сертификат, даже если это не требуется.

Создадим /etc/letsencrypt/create-renew-certificate.sh и сделаем его исполняемым:

  #!/bin/bash

  set -e

  WEBROOT=/var/www/common/letsencrypt

  /bin/letsencrypt certonly --webroot --webroot-path=${WEBROOT} "$@"
  systemctl reload nginx

Теперь мы можем сгенерировать сертификат командой

  $ /etc/letsencrypt/create-renew-certificate.sh \
    --config /etc/letsencrypt/maximov.space.ini

Вместо этого создадим отдельный unit /etc/systemd/system/cert-renew.service, который нам ещё пригодится в дальнейшем:

  [Unit]
  Description=Renew SSL certificates

  [Service]
  Type=oneshot
  ExecStart=/etc/letsencrypt/create-renew-certificate.sh \
    --config /etc/letsencrypt/maximov.space.ini

Запустим этот сервис, чтобы сгенерировать сертификат:

  $ sudo systemctl start cert-renew

При этом в директории /etc/letsencrypt/live/maximov.space/ появятся следующие файлы:

privkey.pem
приватный ключ для сертификата. КО подсказывает, что его нужно хранить в секрете.
cert.pem
сертификат нашего сервера.
chain.pem
цепочка всех сертификатов, начиная с корневого сертификата, которая необходима браузеру для того, чтобы установить подлинность сервера, исключая cert.pem.
fullchain.pem
конкатенация chain.pem и cert.pem.

Время жизни сертификата — три месяца, поэтому необходимо настроить автоматическое обновление сертификата. letsencrypt запоминает параметры создания последнего сертификата, поэтому для обновления достаточно запустить letsencrypt без параметров или с последними использованными параметрами. Воспользуемся созданным ранее сервисом /cert-renew.service, напишем таймер /etc/systemd/system/cert-renew.timer, который будет запускать обновление сертификата каждый месяц:

  [Unit]
  Description=Run cert-renew.service every month

  [Timer]
  OnCalendar=monthly
  Persistent=true

  [Install]
  WantedBy=timers.target

Запустим таймер и добавим его в автозагрузку:

  $ sudo systemctl start cert-renew.timer
  $ sudo systemctl enable cert-renew.timer

Теперь можно настроить HTTPS-сервер, который будет отдавать блог.

Сервер

Настройки SSL — сертификат, разрешённые протоколы и шифры, параметры сессии:

  ssl_certificate /etc/letsencrypt/live/maximov.space/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/maximov.space/privkey.pem;

  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
  ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
  ssl_prefer_server_ciphers on;

  ssl_session_cache shared:SSL:10m;
  ssl_session_timeout 10m;

Помимо уже настроенного перенаправления с HTTP на HTTPS сделаем перенаправление с www.maximov.space на maximov.space для SSL:

  server {
      listen 443 ssl;
      server_name www.maximov.space;

      return 301 https://maximov.space$request_uri;
  }

Секция, которая будет обрабатывать запросы к блогу:

  server {
      listen 443 ssl;
      server_name maximov.space;

      root /var/www/maximov.space;

      keepalive_timeout 70;

      index index.html;
      autoindex off;

      location / {
          try_files $uri $uri/ =404;
      }

      location ~ /\. {
          access_log off;
          log_not_found off;
          deny all;
      }

      location ~ ~$ {
          access_log off;
          log_not_found off;
          deny all;
      }
  }

Перезагрузим конфигурацию nginx, после этого всё должно работать, осталось только добавить unit Nginx в автозагрузку:

  $ sudo systemctl enable nginx