Переходим на HTTPS за 15 минут на примере TeamCity

Kate

Administrator
Команда форума
Всем привет! Меня зовут Александр. Я являюсь техническим руководителем отдела интеграции в компании Visiology. Хочу поделиться с вами нашим велосипедом, как мы перевели наш CI/CD сервер на работу по HTTPS.

Исходные данные​

Был сервер TeamCity, который уже был развёрнут в Docker контейнере и работал по HTTP. А хотелось заставить его работать по HTTPS без сложных настроек и без регистрации и смс бесплатно + получить универсальное решение для любого веб приложения. "Сложные" настройки TeamCity можно найти тут, а роадмап, в котором всё это обещают из коробки, тут. Времени ждать у нас нет, а задачу надо делать, так что поехали.
Кому лень всё читать, то можно заглянуть в репозиторий, все шаги я разбил по коммитам, так же там удобно смотреть изменения между шагами.

Простое веб приложение​

Для начала потренируемся на кошках простом Python приложении.
main.py
Простое приложение, которое поднимается на 8000 порту и отвечает "Hello!". Проверим.
test@test:~/simple-server$ curl http://127.0.0.1:8000
Hello!
test@test:~/simple-server$
Схематично это будет выглядеть вот так
d9853f224c73fa63450d1831d48515a9.PNG

Запакуем в Docker​

Сделаем теперь из него Docker контейнер, чтобы не "засорять" хостовую машину.
Dockerfile
FROM python:3.9-alpine

EXPOSE 8000

COPY main.py /main.py

ENTRYPOINT ["python"]

CMD ["main.py"]
Супер! Билдим, запускаем, проверяем.
test@test:~/simple-server$ docker build -t simple-server .
...
Successfully built 43d18cb628fc
Successfully tagged simple-server:latest

test@test:~/simple-server$ docker run -dt --name simple-server -p 8000:8000 simple-server
7f2d3e770b3ed0f0bfabbd1849486d8c2be99c7eab05ff3c452091e9d4417aec

test@test:~/simple-server$ curl http://127.0.0.1:8000
Hello!
test@test:~/simple-server$
fbf9850cc90047318652de3fe3c0e715.PNG

Теперь, если у нас есть "белый" айпишник и привязанное DNS имя, то можно постучаться к этому серверу извне. Примерно так и работал наш сервер TeamCity, но было страшно за передачу паролей по HTTP, поэтому решено было перейти на HTTPS.
Мы выбрали решение на основе бесплатных сертификатов от Let’s Encrypt. Если выкинуть всю воду, то можно свести к трём шагам:
  1. Получаем сертификат
  2. Подкладываем его nginx серверу
  3. Запускаем контейнеры приложения, nginx, certbot (для обновления сертификата)
За основу я взял эту статью, но как всегда не обошлось без напильника. Потом я нашёл "всё в одном месте", там всё очень хорошо расписано.

Добавим nginx​

Для начала запустим наш сервер в связке с nginx.
Создадим новую папку, например, server_data, внутри неё сделаем ещё папку data, а в ней файл nginx.conf
nginx.conf
server {
resolver 127.0.0.11;
listen 80; # public server port

set $simple_server_url http://simple-server:8000;

location / {
proxy_pass $simple_server_url;
}
}
127.0.0.11 - DNS сервер в докер сети по умолчанию
слушаем на 80 порту
устанавливем переменную simple_server_url, а не сразу пишем в секции location, потому что перед стартом nginx все proxy_pass должны быть доступны, а если контейнер с приложением к этому моенту не стартанёт, то nginx не запустится
simple-server - здесь DNS имя контейнера с нашим python приложением
В папке server_data создадим файл docker-compose.yml
docker-compose.yml
version: '3.8'

services:
simple-server:
container_name: simple-server
image: simple-server
restart: unless-stopped
networks:
nginx_net:

nginx:
container_name: nginx
image: nginx:1.21.1
restart: unless-stopped
volumes:
- ./data/nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- 80:80
networks:
nginx_net:

networks:
nginx_net:
Запускаем docker-compose up -d из папки server_data. Проверяем уже на 80 порту, потому что наше приложение теперь находится за nginx.
test@test:~/server_data$ docker-compose up -d
Creating nginx ... done
Creating simple-server ... done
test@test:~/server_data$ curl http://127.0.0.1
Hello!
test@test:~/server_data$
Отлично! Теперь у нас есть приложение, которое работает за nginx по HTTP. Самое время перевести нашу конфигурацию на HTTPS. Для дальнейшего продолжения убедитесь, что в файле nginx.conf значение переменной $server_url - верное DNS имя именно этой машины, без этого получить SSL сертификат не получится.
59343f942edf45bcf433449d3750f04d.PNG

Получаем сертификат​

Скрипт из статьи у меня не заработал из-за битых ссылок, решил взять другой отсюда.
Для данного скрипта важны названия сервисов из docker-compose.yml.
init-letsencrypt.sh
#!/bin/bash

if ! [ -x "$(command -v docker-compose)" ]; then
echo 'Error: docker-compose is not installed.' >&2
exit 1
fi

domains=(example.org www.example.org)
rsa_key_size=4096
data_path="./data/certbot"
email="" # Adding a valid address is strongly recommended
staging=1 # Set to 1 if you're testing your setup to avoid hitting request limits

if [ -d "$data_path" ]; then
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
exit
fi
fi


if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
echo "### Downloading recommended TLS parameters ..."
mkdir -p "$data_path/conf"
curl -s https://raw.githubusercontent.com/c.../_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
echo
fi

echo "### Creating dummy certificate for $domains ..."
path="/etc/letsencrypt/live/$domains"
mkdir -p "$data_path/conf/live/$domains"
docker-compose run --rm --entrypoint "\
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
-keyout '$path/privkey.pem' \
-out '$path/fullchain.pem' \
-subj '/CN=localhost'" certbot
echo


echo "### Starting nginx ..."
docker-compose up --force-recreate -d nginx
echo

echo "### Deleting dummy certificate for $domains ..."
docker-compose run --rm --entrypoint "\
rm -Rf /etc/letsencrypt/live/$domains && \
rm -Rf /etc/letsencrypt/archive/$domains && \
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
echo


echo "### Requesting Let's Encrypt certificate for $domains ..."
#Join $domains to -d args
domain_args=""
for domain in "${domains[@]}"; do
domain_args="$domain_args -d $domain"
done

# Select appropriate email arg
case "$email" in
"") email_arg="--register-unsafely-without-email" ;;
*) email_arg="--email $email" ;;
esac

# Enable staging mode if needed
if [ $staging != "0" ]; then staging_arg="--staging"; fi

docker-compose run --rm --entrypoint "\
certbot certonly --webroot -w /var/www/certbot \
$staging_arg \
$email_arg \
$domain_args \
--rsa-key-size $rsa_key_size \
--agree-tos \
--force-renewal" certbot
echo

echo "### Reloading nginx ..."
docker-compose exec nginx nginx -s reload
В скрипте init-letsencrypt.shменяем значения переменных в 8, 11 и 12 строчке. Значение параметра staging лучше оставить 1, пока скрипт не отработает без ошибок. Если вдруг ваше DNS имя содержит не только латинские буквы, дефис и цифры, то адрес надо писать в формате Punycode.
Ещё нужно будет поменять файлы docker-compose.yml и nginx.conf. В файле docker-compose.yml добавляется сервис certbot, а в сервисе nginx добавляются общие с certbot volumes, открываем 443 порт и добавляем секцию command, которая перезагружает nginx каждые 6 часов, чтобы подгрузить новые сертификаты, если они изменились. Сервис certbot отвечает за взаимодействие с сервером Let's Encrypt и обновлением SSL сертификата. Приведу файл целиком.
docker-compose.yml
version: '3.8'

services:
simple-server:
container_name: simple-server
image: simple-server
restart: unless-stopped
networks:
nginx_net:

nginx:
container_name: nginx
image: nginx:1.21.1
restart: unless-stopped
volumes:
- ./data/nginx.conf:/etc/nginx/conf.d/default.conf
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot
ports:
- 80:80
- 443:443
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
networks:
nginx_net:

certbot:
container_name: certbot
image: certbot/certbot:v1.17.0
restart: unless-stopped
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
nginx_net:
volumes:
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot

networks:
nginx_net:

Но ещё требуется изменить файл nginx.conf, в него добавится специальная секция location, которая будет перенаправлять запросы к certbot.
nginx.conf
server {
resolver 127.0.0.11;
listen 80; # public server port

location /.well-known/acme-challenge/ { root /var/www/certbot; }

set $simple_server_url http://simple-server:8000;

location / {
proxy_pass $simple_server_url;
}
}

Запускаем скрипт init-letsencrypt.sh с параметром staging=1. Если всё настроено правильно, то будет сообщение с поздравлением.
8ece41a463bc984ca2a8ef2bfa546c66.png

Тогда можно запускать скрипт с параметром staging=0. Если что-то пошло не так, а у меня такое было пару раз, то советую проверить следующее:
  1. Проверить правильность конфига nginx.
  2. Проверить docker-compose.yml на наличие общих volume у nginx и certbot.
  3. Добавить set -x в начало скрипта для дебага (будут выводиться исполняемые команды).
  4. Для дебага в скрипте init-letsencrypt.sh можно убрать аргумент --rm в 69 строчке, тогда контейнер не удалится и можно будет вытащить из него лог. И добавить аргумент -v перед \ в 73 строчке для более полного логирования.
  5. Если совсем беда, то можно включить debug логирование на nginx.
Не забудьте запустить скрипт с параметром staging=0, иначе сертификат не появится и HTTPS работать не будет.

Окончательная настройка​

Итак, осталось совсем чуть-чуть. Нужно настроить nginx для работы по HTTPS и всё. Откроем 443 порт, добавим путь к SSL сертификатам, пути к настройкам letsencrypt и редирект с HTTP на HTTPS. Во всех примерах ниже будет фигурировать <URL>, его нужно менять на ваш, в файле ниже это 2, 10 и 11 строчка. Не забываем про Punycode, если требуется. Пару раз при тесте certbot создавал папки с префиксом www., это ещё одна возможная причина неуспешной конфигурации.
nginx.conf
server {
resolver 127.0.0.11;
listen 80; # public server port
listen 443 ssl;

ssl_certificate /etc/letsencrypt/live/<URL>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<URL>/privkey.pem;

include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

location /.well-known/acme-challenge/ { root /var/www/certbot; }

if ($server_port = 80) { set $https_redirect 1; }
if ($host ~ '^www\.') { set $https_redirect 1; }
if ($https_redirect = 1) { return 301 https://$server_url$request_uri; }

set $simple_server_url http://simple-server:8000;

location / {
proxy_pass $simple_server_url;
}
}
Запускаем наши сервисы
docker-compose up --force-recreate -d
И заходим в браузере на <URL> по HTTP, должен сработать редирект на HTTPS.
d246183a64a2282f1e30210e46a16bf8.png

Всё получилось, поздравляю! Таким способом можно перевести любое веб приложение на работу по HTTPS, даже для такого, которое не поддерживает HTTPS из коробки.
e26987315bb15a867f8a9c954191225b.PNG

Перейдём к TeamCity​

TeamCity можно долго оптимизировать, в этой статье я возьму "минимальную" конфигурацию, чтобы "просто запустилось". Для TeamCity единственное отличие - это настройка nginx.conf для работы с сокетами и некоторые дополнительные оптимизации. Берём рекомендованный конфиг отсюда и объединяем с нашим БЕЗ строчкии http. Ещё важное отличие, что для работы TeamCity нужна правильно настроенная секция server_name, в примере с Python приложением её можно было не заполнять. Получаем такой:
nginx.conf
# ... default settings here
proxy_read_timeout 1200;
proxy_connect_timeout 240;
client_max_body_size 0; # maximum size of an HTTP request. 0 allows uploading large artifacts to TeamCity

map $http_upgrade $connection_upgrade { # WebSocket support
default upgrade;
'' '';
}

server {
server_name <URL> www.<URL>; # public server host name

resolver 127.0.0.11;
listen 80; # public server port
listen 443 ssl; # public server port

ssl_certificate /etc/letsencrypt/live/<URL>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<URL>/privkey.pem;

include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

location /.well-known/acme-challenge/ { root /var/www/certbot; }

if ($server_port = 80) { set $https_redirect 1; }
if ($host ~ '^www\.') { set $https_redirect 1; }
if ($https_redirect = 1) { return 301 https://$server_url$request_uri; }

set $teamcity_server_url http://teamcity-server-instance:8111;

location / { # public context (should be the same as internal context)
proxy_pass $teamcity_server_url; # full internal address
proxy_http_version 1.1;
proxy_set_header Host $server_name:$server_port;
proxy_set_header X-Forwarded-Host $http_host; # necessary for proper absolute redirects and TeamCity CSRF check
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Upgrade $http_upgrade; # WebSocket support
proxy_set_header Connection $connection_upgrade; # WebSocket support
}
}
И файл docker-compose.yml
docker-compose.yml
version: '3.8'

services:
teamcity_server:
container_name: teamcity-server-instance
image: jetbrains/teamcity-server:2021.1
restart: unless-stopped
tty: true
networks:
nginx_net:

nginx:
container_name: nginx
image: nginx:1.21.1
restart: unless-stopped
volumes:
- ./data/nginx.conf:/etc/nginx/conf.d/default.conf
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot
ports:
- 80:80
- 443:443
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
networks:
nginx_net:

certbot:
container_name: certbot
image: certbot/certbot:v1.17.0
restart: unless-stopped
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
nginx_net:
volumes:
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot

networks:
nginx_net:

Запускаем с помощью команды docker-compose up -d, переходим в браузер по URL и настраиваем TeamCity.

Добавляем агента​

Добавим билд агента, который может быть расположен в любой точке мира. Для этого достаточно запустить на любой машине с докером такую команду
docker run -dt -e SERVER_URL="https://<URL>" \
--name teamcity-agent-instance jetbrains/teamcity-agent:2021.1
493a1c8883a2066b71801229126c6231.PNG

Агент должен появиться в веб интерфейсе TeamCity на вкладке с агентами.

Агент в локальной сети​

Предположим, что у нас есть 2 сервера в локальной сети. Первый будет отвечать за сервер TeamCity, а второй будет билд агентом, но у него не будет доступа в интернет. Будем считать, что сеть защённая и там можно общаться по HTTP. Тогда запуск агента с таким параметром SERVER_URL="https://<URL>" не подойдёт, а подойдёт SERVER_URL="http://<SERVER_LOCAL_IP>". Придётся ещё поменять конфиг nginx - добавить туда секцию для локальной сети
nginx.conf
# ... default settings here
proxy_read_timeout 1200;
proxy_connect_timeout 240;
client_max_body_size 0; # maximum size of an HTTP request. 0 allows uploading large artifacts to TeamCity

map $http_upgrade $connection_upgrade { # WebSocket support
default upgrade;
'' '';
}

server {
resolver 127.0.0.11;
listen 80; # server port
server_name <SERVER_LOCAL_IP>; # private server host name

set $teamcity_url http://teamcity-server-instance:8111;

location / { # public context (should be the same as internal context)
proxy_pass $teamcity_url; # full internal address
proxy_http_version 1.1;
proxy_set_header Host $server_name:$server_port;
proxy_set_header X-Forwarded-Host $http_host; # necessary for proper absolute redirects and TeamCity CSRF check
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Upgrade $http_upgrade; # WebSocket support
proxy_set_header Connection $connection_upgrade; # WebSocket support
}
}

server {
resolver 127.0.0.11;
listen 80; # public server port
listen 443 ssl;
server_name <URL>; # public server host name

ssl_certificate /etc/letsencrypt/live/<URL>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<URL>/privkey.pem;

include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

location /.well-known/acme-challenge/ { root /var/www/certbot; }

if ($server_port = 80) { set $https_redirect 1; }
if ($host ~ '^www\.') { set $https_redirect 1; }
if ($https_redirect = 1) { return 301 https://<URL>$request_uri; }

set $teamcity_server_url http://teamcity-server-instance:8111;

location / {
proxy_pass $teamcity_server_url;
proxy_http_version 1.1;
proxy_set_header Host $server_name:$server_port;
proxy_set_header X-Forwarded-Host $http_host; # necessary for proper absolute redirects and TeamCity CSRF check
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Upgrade $http_upgrade; # WebSocket support
proxy_set_header Connection $connection_upgrade; # WebSocket support
}
}
87234009c3e9f8a0dea93c4983e85e81.PNG

На вкладке в с агентами не должно быть изменений, но теперь общение между сервером и агентом будет идти в локальной сети.

Ещё улучшений?​

А ещё можно развернуть сервер и билд агент в Docker Swarm или Kubernetes, но это уже совсем другая история "лучшее - враг хорошего", и мы решили остановиться. Полученная конфигурация позволила решить все наши задачи в полном объёме и не требует трудоёмкой поддержки. Добавление агентов происходит одной командой, что очень удобно.
Ещё раз приведу ссылку на репозиторий с шагами. Спасибо за внимание!

 
Сверху