Ansible: миграция RTFM 2.10 – Let’s Encrypt, NGINX SSL, hostname и exim

By | 02/10/2018
 

Предыдущий пост серии – Ansible: миграция RTFM 2.9 – монтирование EBS и настройка NGINX на Bastion.

Сегодня надо выполнить установку и настройку:

  1. Let’s Encrypt
  2. SSL на NGINX и виртуалхоста (пока только dev.rtfm.co.ua)
  3. hostname
  4. exim

Ссылки на коммиты файлов, получившиеся в результате написания этого поста:

  1. roles/letsencrypt/tasks/main.yml
  2. rtfm-blog-ansible-bastion-provision.yml
  3. roles/nginx/templates/nginx.conf
  4. hosts
  5. roles/common/tasks/main.yml
  6. roles/exim/templates/update-exim4.conf.conf.j2
  7. roles/exim/templates/mailname.j2
  8. roles/exim/tasks/main.yml

Let’s Encrypt

Для Let’s Encrypt напишем свою роль, которая будет только выполнять установку клиента и добавлять cron-задачу для обновления сертификатов.

Сами сертификаты и все настройки Let’s Encrypt будут храниться на data-диске – EBS-разделе, который подключается к EC2 во время создания стека:

root@ip-10-0-1-19:/home/admin# lsblk
NAME    MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
xvda    202:0    0   8G  0 disk
└─xvda1 202:1    0   8G  0 part /
xvdb    202:16   0   8G  0 disk
└─xvdb1 202:17   0   8G  0 part /data

Сейчас в каталоге /data размещаются только конфиги NGINX:

root@ip-10-0-1-19:/home/admin# ls -l /data/*
/data/data-nginx.d:
total 4
-rwxr-xr-x 1 root root 324 Feb  3 13:49 dev.rtfm.co.ua.conf
-rwxr-xr-x 1 root root   0 Feb  3 14:38 test.file
/data/lost+found:
total 0

На рабочей машине создаём каталог для роли Let’s Encrypt:

mkdir -p roles/letsencrypt/tasks

Для установки потребуется:

  1. создать каталог /data/letsencrypt – он будет использоваться вместо дефолтного /etc/letsecrypt (опция --config-dir), если его нет
  2. установить клиент letsencrypt
  3. добавить cron-задачу для renew

Создаём файл roles/letsencrypt/tasks/main.yml, добавляем создание каталога:

- name: Create Let's Encrypt directory
  file:
    path=/data/letsencrypt
    state=directory

И установку Let’s Encrypt клиента (certbot):

...
- name: Install Let's Encrypt client
  apt:
    name=letsencrypt
    state=latest

В файл плейбука rtfm-blog-ansible-bastion-provision.yml добавляем вызов роли после роли common (в которой выполняется подключение раздела /dev/xvdb1):

- hosts: all
  become:
    true
  roles:
    - role: common
    - role: letsencrypt
...

Проверяем:

ansible-playbook --syntax-check --limit=rtfm-bastion-dev rtfm-blog-ansible-bastion-provision.yml
playbook: rtfm-blog-ansible-bastion-provision.yml

Запускаем:

ansible-playbook --syntax-check -limit=rtfm-bastion-dev --private-key=../../Bitbucket/aws-credentials/rtfm-dev.pemible-bastion-provision.yml
PLAY [all] ****
TASK [Gathering Facts] ****
ok: [dev.rtfm.co.ua]
TASK [common : Create "/data" directory] ****
ok: [dev.rtfm.co.ua]
TASK [common : Mount data-volume "/dev/xvdb1" to "/data"] ****
ok: [dev.rtfm.co.ua]
TASK [common : Install common packages] ****
ok: [dev.rtfm.co.ua] => (item=[u'mailutils', u'curl'])
TASK [letsencrypt : Create Let's Encrypt certs directory] ****
changed: [dev.rtfm.co.ua]
TASK [letsencrypt : Install Let's Encrypt client] ****
changed: [dev.rtfm.co.ua]
...

Проверяем каталог:

root@ip-10-0-1-19:/home/admin# ls -l /data/letsencrypt/
total 0

Клиент:

root@ip-10-0-1-19:/home/admin# letsencrypt --version
certbot 0.10.2

ОК – попробуем получить сертификат с опциями:

  • --config-dir: путь к каталогу с настройками  и файлами ключей
  • --noninteractive: не запрашивать ввода данных от пользователя
  • --webroot: авторизация через webroot:
    • --webroot-path – путь к location .well-known
  • --email: почта для уведомлений и восстановления
  • --agree-tos: согласиться с ACME Subscriber Agreement
root@ip-10-0-1-19:/home/admin# letsencrypt certonly --config-dir /data/letsencrypt/ --noninteractive --webroot --webroot-path /var/www/html/ --email letsencrypt@domain.org.ua --agree-tos --domains dev.rtfm.co.ua
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for dev.rtfm.co.ua
Using the webroot path /var/www/html for all unmatched domains.
Waiting for verification...
Cleaning up challenges
Generating key (2048 bits): /data/letsencrypt/keys/0000_key-certbot.pem
Creating CSR: /data/letsencrypt/csr/0000_csr-certbot.pem
Non-standard path(s), might not work with crontab installed by your operating system package manager
IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at
/data/letsencrypt/live/dev.rtfm.co.ua/fullchain.pem. Your cert will
expire on 2018-05-11. To obtain a new or tweaked version of this
certificate in the future, simply run certbot again. To
non-interactively renew *all* of your certificates, run "certbot
renew"
...

Отлично – работает.

Проверям сертификаты:

root@ip-10-0-1-19:/home/admin# certbot --config-dir /data/letsencrypt/ certificates
Saving debug log to /var/log/letsencrypt/letsencrypt.log
-------------------------------------------------------------------------------
Found the following certs:
Certificate Name: dev.rtfm.co.ua
Domains: dev.rtfm.co.ua
Expiry Date: 2018-05-11 11:39:00+00:00 (VALID: 89 days)
Certificate Path: /data/letsencrypt/live/dev.rtfm.co.ua/fullchain.pem
Private Key Path: /data/letsencrypt/live/dev.rtfm.co.ua/privkey.pem
-------------------------------------------------------------------------------

Проверяем каталог с данными:

root@ip-10-0-1-19:/home/admin# ls -l /data/letsencrypt/*
/data/letsencrypt/accounts:
total 4
drwx------ 3 root root 4096 Feb 10 12:38 acme-v01.api.letsencrypt.org
/data/letsencrypt/archive:
total 4
drwxr-xr-x 2 root root 4096 Feb 10 12:39 dev.rtfm.co.ua
/data/letsencrypt/csr:
total 4
-rw-r--r-- 1 root root 956 Feb 10 12:38 0000_csr-certbot.pem
/data/letsencrypt/keys:
total 4
-rw------- 1 root root 1704 Feb 10 12:38 0000_key-certbot.pem
/data/letsencrypt/live:
total 4
drwxr-xr-x 2 root root 4096 Feb 10 12:39 dev.rtfm.co.ua
/data/letsencrypt/renewal:
total 4
-rw-r--r-- 1 root root 585 Feb 10 12:39 dev.rtfm.co.ua.conf

ОК – теперь можно пересоздавать стек, а сертификаты будут на прежнем месте – на EBS разделе.

Добавим в роль letsencrypt создание cron-задачи.

Проверяем работу renew:

root@ip-10-0-1-19:/home/admin# certbot --config-dir /data/letsencrypt/ renew
Saving debug log to /var/log/letsencrypt/letsencrypt.log
-------------------------------------------------------------------------------
Processing /data/letsencrypt/renewal/dev.rtfm.co.ua.conf
-------------------------------------------------------------------------------
Cert not yet due for renewal
The following certs are not due for renewal yet:
/data/letsencrypt/live/dev.rtfm.co.ua/fullchain.pem (skipped)
No renewals were attempted.

Возвращаемся к roles/letsencrypt/tasks/main.yml:

...
- name: Add Let's Encrypt cronjob for cert renewal
  cron:
    name: letsencrypt_renewal
    special_time: weekly
    job: letsencrypt --config-dir /data/letsencrypt/ renew &> > /var/log/letsencrypt/letsencrypt.log && service nginx reload

Запускаем:

ansible-playbook --limit=rtfm-bastion-dev --private-key=../../Bitbucket/aws-credentials/rtfm-dev.pem rtfm-blog-ansible-bastion-provision.yml

Проверяем:

root@ip-10-0-1-19:/home/admin# crontab -l
Ansible: letsencrypt_renewal
@weekly letsencrypt --config-dir /data/letsencrypt/ renew &> > /var/log/letsencrypt/letsencrypt.log && service nginx reload

NGINX SSL

Обновим настройки самого NGINX, добавим параметры SSL (см. пост OpenBSD: установка NGINX и настройки безопасности).

Сначала выполним на сервер, проверим, потом обновим roles/nginx/templates/nginx.conf и шаблон виртуалхоста в Bitbucket.

Отключаем афиширование версии NGINX:

...
server_tokens off;
...

Обновляем параметры SSL:

...
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_prefer_server_ciphers on;
        ssl_ciphers  "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH !RC4";
        ssl_session_cache   shared:SSL:10m;
        ssl_session_timeout 1h;
        ssl_stapling on;
        ssl_stapling_verify on;
...

Обновляем параметры SSL для виртуалхоста dev.rtfm.co.ua (сам шаблон хранится в приватном репозитории Bibucket).

Добавляем HSTS:

...
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
...

Отключаем проверку MIME-типов – добавляем X-Content-Type-Options:

...
add_header X-Content-Type-Options nosniff;
...

Включаем SSL:

...
listen 443 ssl;
...
ssl_certificate /data/letsencrypt/live/dev.rtfm.co.ua/fullchain.pem;
ssl_certificate_key /data/letsencrypt/live/dev.rtfm.co.ua/privkey.pem;
...

Добавляем новый server {} на порту 80, и выполняем редирект на 443:

server {

    listen 80;
    server_name dev.rtfm.co.ua;
    return 301 https://dev.rtfm.co.ua$request_uri;
}
...

Полностью файл /data/data-nginx.d/dev.rtfm.co.ua.conf сейчас выглядит так:

server {

    listen 80;
    server_name dev.rtfm.co.ua;
    return 301 https://dev.rtfm.co.ua$request_uri;
}

server {

    server_name dev.rtfm.co.ua;

    listen 443 ssl;

    access_log /var/log/nginx/dev.rtfm.co.ua-access.log proxy;
    error_log /var/log/nginx/dev.rtfm.co.ua-error.log notice;

    ssl_certificate /data/letsencrypt/live/dev.rtfm.co.ua/fullchain.pem;
    ssl_certificate_key /data/letsencrypt/live/dev.rtfm.co.ua/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
    add_header X-Content-Type-Options nosniff;

    root /var/www/html;

        index index.html index.htm index.nginx-debian.html;

    location ~ /.well-known {
        allow all;
    }

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

Проверяем, перечитываем конфиги:

root@ip-10-0-1-19:/etc/nginx# nginx -t && service nginx reload
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Проверяем:

curl -Iv dev.rtfm.co.ua
* Rebuilt URL to: dev.rtfm.co.ua/
*   Trying 34.243.86.146...
* TCP_NODELAY set
* Connected to dev.rtfm.co.ua (34.243.86.146) port 80 (#0)
> HEAD / HTTP/1.1
> Host: dev.rtfm.co.ua
> User-Agent: curl/7.58.0
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
HTTP/1.1 301 Moved Permanently
< Server: nginx
Server: nginx
< Date: Sat, 10 Feb 2018 13:20:01 GMT
Date: Sat, 10 Feb 2018 13:20:01 GMT
< Content-Type: text/html
Content-Type: text/html
< Content-Length: 178
Content-Length: 178
< Connection: keep-alive
Connection: keep-alive
< Location: https://dev.rtfm.co.ua/
Location: https://dev.rtfm.co.ua/
<
* Connection #0 to host dev.rtfm.co.ua left intact

Всё хорошо – редирект работает, SSL тоже:

Проверяем уровень на https://www.ssllabs.com/ssltest/:

B – а хочется A+, сейчас исправим – добавим генерацию ключа Diffie-Hellman.

Обновляем роль nginx, в roles/nginx/tasks/main.yml добавляем генерацию ключа:

...
- name: Generate dhparams
  shell: openssl dhparam -out /etc/nginx/dhparams.pem 2048
  args:
    creates: /etc/nginx/dhparams.pem
...

Обновляем roles/nginx/templates/nginx.conf аналогично тому, как делали на сервере, но добавляем:

...
ssl_dhparam /etc/nginx/dhparams.pem;
...

Обновляем шаблон ~/Work/RTFM/Bitbucket/rtfm-blog-ansible-templates/dev.rtfm.co.ua.conf (сейчас шаблон копируется из роли nginx, но надо будет создать отдельную задачу в Jenkins для обновления конфигов виртуалхостов NGINX).

Проверяем:

git add rtfm-bansible-playbook --syntax-check --limit=rtfm-bastion-dev rtfm-blog-ansible-bastion-provision.yml
playbook: rtfm-blog-ansible-bastion-provision.yml

Запускаем:

ansible-playbook --limit=rtfm-bastion-dev --private-key=../../Bitbucket/aws-credentials/rtfm-dev.pem rtfm-blog-ansible-bastion-provision.yml
...
TASK [nginx : Install Nginx] ****
ok: [dev.rtfm.co.ua]
TASK [nginx : Replace NGINX config] ****
changed: [dev.rtfm.co.ua]
TASK [nginx : Generate dhparams] ****
changed: [dev.rtfm.co.ua]
TASK [nginx : Service NGINX reload] ****
changed: [dev.rtfm.co.ua]
...

Проверяем:

ОК – есть А+.

Наверное – стоит всё-таки сейчас удалить весь стек, пересоздать его заново, и проверить, что всё работает. Потом можно добавить настройку hostname и exim.

Удаляем CloudFormation стек:

aws cloudformation delete-stack --stack-name rtfm-dev

Создаём его заново:

cd ~/Work/RTFM/Github/rtfm-blog-cf-templates/
aws cloudformation --region eu-west-1 create-stack --stack-name rtfm-dev --template-body file://rtfm-blog-cf-template.json --parameters ParameterKey=HomeAllowLocation,ParameterValue=188.***.***.114/32 ParameterKey=JenkinsIP,ParameterValue=52.***.***.34/32

Запускаем Ansible:

ansible-playbook --limit=rtfm-bastion-dev --private-key=../../Bitbucket/aws-credentials/rtfm-dev.pem --extra-vars api_key=967***e31 rtfm-blog-ansible-bastion-provision.yml

Проверяем:

curl -IL dev.rtfm.co.ua
HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Sat, 10 Feb 2018 14:22:37 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive
Location: https://dev.rtfm.co.ua/
HTTP/1.1 200 OK
Server: nginx
Date: Sat, 10 Feb 2018 14:22:37 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Sat, 10 Feb 2018 14:10:37 GMT
Connection: keep-alive
ETag: "5a7efd5d-264"
Strict-Transport-Security: max-age=31536000; includeSubdomains
X-Content-Type-Options: nosniff
Accept-Ranges: bytes

Всё работает.

hostname

Для корректной работы почты – надо настроить hostname, см. детали в посте Exim: Mailing to remote domains not supported.

Добавим переменную set_ostname в файл hosts:

...
[rtfm-bastion-dev:vars]
data_volume_id="/dev/xvdb1"
set_hostname="rtfm-bastion-dev"
...

Обновляем роль common, в файле roles/common/tasks/main.yml используем модуль hostname:

...
- name: Set hostname
  hostname:
    name: "{{ set_hostname }}"

Для обновления файла /etc/hosts – используем модуль lineinfile, добавляем его тут же, следующим:

...
- name: Add hostnames to /etc/hosts
  lineinfile:
    dest: /etc/hosts
    regexp: '^127\.0\.0\.1[ \t]+localhost'
    line: "127.0.0.1 localhost {{ set_hostname }} {{ inventory_hostname }}"
    state: present

Т.к. это EC2 – то файл /etc/hosts обновляется при рестартах:

root@ip-10-0-1-15:/home/admin# head /etc/hosts
Your system has configured 'manage_etc_hosts' as True.
As a result, if you wish for changes to this file to persist
then you will need to either
a.) make changes to the master file in /etc/cloud/templates/hosts.tmpl
b.) change or remove the value of 'manage_etc_hosts' in
/etc/cloud/cloud.cfg or cloud-config from user-data
127.0.1.1 ip-10-0-1-15.eu-west-1.compute.internal ip-10-0-1-15
127.0.0.1 localhost

Находим параметр:

root@ip-10-0-1-15:/home/admin# grep -r manage_etc_hosts /etc/
/etc/cloud/cloud.cfg.d/01_debian_cloud.cfg:manage_etc_hosts: true

Добавляем изменения для /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg:

...
- name: Update /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg
  lineinfile:
    dest: /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg
    regexp: '^manage_etc_hosts: true'
    line: "manage_etc_hosts: false"
    state: present

Запускаем:

ansible-playbook --limit=rtfm-bastion-dev --private-key=../../Bitbucket/aws-credentials/rtfm-dev.pem rtfm-blog-ansible-bastion-provision.yml

Проверяем:

root@ip-10-0-1-15:/home/admin# cat /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg | grep manage
manage_etc_hosts: false

exim

Последняя задача на сегодня – настроить exim на отправку почты “в мир”.

Для этого надо изменить два файла – /etc/exim4/update-exim4.conf.conf и /etc/mailname.

Создадим роль exim:

mkdir -p roles/exim/{tasks,templates}

В templates добавим шаблон update-exim4.conf.conf.j2:

dc_eximconfig_configtype='internet'
dc_other_hostnames='"{{ inventory_hostname }}"'
dc_local_interfaces='127.0.0.1 ; ::1'
dc_readhost=''
dc_relay_domains=''
dc_minimaldns='false'
dc_relay_nets=''
dc_smarthost=''
CFILEMODE='644'
dc_use_split_config='false'
dc_hide_mailname=''
dc_mailname_in_oh='true'
dc_localdelivery='mail_spool'

И mailname.j2 с одной строкой:

{{ inventory_hostname }}

В roles/exim/tasks/main.yml описываем применение шаблонов, перезагрузку exim и отправку тестового сообщения:

- name: Update Exim4 settings
  template:
    src=templates/update-exim4.conf.conf.j2
    dest=/etc/exim4/update-exim4.conf.conf

- name: Update mailname
  template:
    src=templates/mailname.j2
    dest=/etc/mailname

- name: Exim4 restart
  service:
    name=exim4
    state=restarted

- name: Send test email
  shell:
    echo "Eximt4 config complete" | mailx -s "{{ inventory_hostname }} Exim4 test"  notify@domain.kiev.ua

Добавляем роль exim в rtfm-blog-ansible-bastion-provision.yml:

- hosts: all
  become:
    true
  roles:
    - role: common
    - role: exim
...

Запускаем:

vim roles/eximansible-playbook --limit=rtfm-bastion-dev --private-key=../../Bitbucket/aws-credentials/rtfm-dev.pem rtfm-blog-ansible-bastion-provision.yml
...
TASK [common : Set hostname] ****
ok: [dev.rtfm.co.ua]
TASK [common : Add hostnams to /etc/hosts] ****
ok: [dev.rtfm.co.ua]
TASK [common : Update /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg] ****
changed: [dev.rtfm.co.ua]
TASK [exim : Update Exim4 settings] ****
changed: [dev.rtfm.co.ua]
TASK [exim : Update mailname] ****
changed: [dev.rtfm.co.ua]
TASK [exim : Exim4 restart] ****
changed: [dev.rtfm.co.ua]
TASK [exim : Send test email] ****
changed: [dev.rtfm.co.ua]
...

Проверяем:

root@ip-10-0-1-15:/home/admin# cat /var/log/exim4/mainlog | grep notify
2018-02-10 15:01:45 1ekWet-0004rj-Pz => notify@domain.kiev.ua R=dnslookup T=remote_smtp H=mail.domain.kiev.ua [77.***.***.20] X=TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256 CV=yes DN="CN=mail.domain.kiev.ua" C="250 OK id=1ekWeu-0001lI-IU"

Готово.

Ещё надо обновить почту root, добавить копирование .bashrc, .vimrc и установку fail2ban – но это уже в следующий раз.