Всем известно, что с помощью ssh можно делать перенаправление портов (создавать туннели). Еще из мануала по ssh вы могли узнать, что OpenSSH умеет динамически открывать порты для удаленного перенаправления и выполнять строго определенные команды. Также всем известно, что для Ansible (не считая Tower) нет такого понятия как сервер и клиент (в смысле ansible-server/ansible-agent) — есть сценарий (playbook) который можно выполнить как локально, так и удаленно через ssh-соединение. Еще есть Ansible-pull, это скрипт который проверяет git-репозиторий с вашими плейбуками и при наличии изменений запускает плейбук для применения обновлений. Там где нельзя пушить в большинстве случаев можно использовать pull, но бывают исключения.

В статье я попробую рассказать о том как можно использовать динамическое выделение портов для ssh-туннелей в реализации подобия функции provisioning-callback для бедных на любом сервере с OpenSSH и Ansible, и как я до этого дошел.

Итак, что если Вам всё-таки нужен (центральный) сервер на котором будет храниться ansible-проект, возможно даже c секретными ключами и доступом ко всей инфраструктуре. Сервер к которому (например) смогут подключаться новые хосты для первичной настройки (инициализации). Иногда такая инициализация может затрагивать другие хосты например http-балансировщик.  Здесь как нельзя кстати может помочь удаленное перенаправление ssh-портов и обратный коннект (ssh back-connect).

В принципе с помощью туннелей можно делать разное полезное, в частности обратный коннект полезен для:
  • Удаленной поддержки машин за NAT (типа helpshell, настраивать окружение, что-то чинить и пр.);
  • Выполнения резервного копирования (не знаю зачем, но можно);
  • Настройки доступа до своего рабочего места в офисе.

В общем, тут можно наверное еще что-то напридумывать, всё зависит от вашей фантазии. Пока остановимся на том что такое обратный ssh-коннект, об этом далее.

Краткая справка как происходит обратный коннект


Никакой магии, только OpenSSH-Client и OpenSSH-Server. Весь процесс создания туннеля клиентом в одной команде:
ssh -f -N -T -R22222:localhost:22 server.example.com

-f — переход в фоновый режим; (этот ключ и следующие два опциональны)

-N — не выполнять удаленных команд;

-T — отключение псевдо-терминала (pts) полезно если нужно запускать эту команду по крону.

-R [bind_address:]port — по умолчанию на сервере биндинг происходит на 127.0.0.1, порт может быть произвольным (из верхних портов), мы за��али 22222. Соответственно обратно можно подключиться на 127.0.0.1 порт 22 клиента.

После на сервере можно просто выполнить:
ssh localhost -p22222

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

Немного подробнее про настройку и авторизацию


Если вы всё про это знаете, то можно пропустить эту часть.

Читать если не знаете как настраивать доступ по ключам
Допустим у нас есть юзер ansible на центральном сервере (SCM/Backup/CI/etc) и такой юзер же на клиентской машине (имена не принципиальны, могут быть разными). На обоих установлен openssh-(server/client).

На клиентской машине (как и на сервере) генерим ssh-ключ (например rsa).
ssh-keygen -b 4096 -t rsa -f $HOME/.ssh/id_rsa

Клиент и администратор сервера обмениваются публичными ключами. Администратор центрального сервера при этом должен прописать в authorized_keys что-то вроде этого:
$ cat  $HOME/.ssh/authorized_keys

command="echo 'Connect successful!'; countdown 3600",no-agent-forwarding,no-X11-forwarding" ssh-rsa AAAAB3NzaC1...JhPWP ansible@dev.example.com

Подробнее об опциях читайте в «man authorized_keys». В моем случае здесь срабатывает функция которая делает обратный отсчет, через час происходит отключение  (ключи -f/-N при этом не используются).

После этого клиент сможет сделать бекконнект к серверу и увидеть что-то вроде этого:
ansible@dev:~$ ssh -f -N -T -R22222:localhost:22 server.example.com
Connect successful!
00:59:59

Увидев обратный отсчет пользователь (разработчик/бухгалтер?!) радостно сообщает администратору сервера что есть прием (если вдруг он еще не знает) и можно уже чего-то там у него делать.

Администратору остается только выполнить подключение с помощью ssh-клиента и начинать шаманить:
ansible@server:~$ ssh localhost -p22222

Всё просто, понятно и доступно.

Но что если нужно делать такое подключение без участия пользователя и администратора, например для резервного копирования или автоматизации настройки серверов при масштабировании веб-проекта с динамическим увеличением вычислительных мощностей. Об этом далее.


От идеи до готового решения


Идея автоматизировать рутинные процессы и держать всё под контролем довольно навязчива и знакома (пожалуй) для любого системного администратора.

Если на серверах у вас сейчас полный порядок, то про рабочее окружение разработчика вы обычно мало знаете. В моем случае рабочее окружение разработчика связано с виртуальной машиной (ВМ) практически полностью повторяющей продакшн.

Люди приходят и уходят, меняется базовый образ ВМ который мы раздаем новичкам. Чтобы синхронизировать настройки локального dev-окружения со stage/production и реже делать ручную работу, был написан плейбук который применял роли аналогично с боевым окружением и заведен соответствующий cron-job.
В принципе это всё хорошо, ВМ получают обновления в pull-mode. Но пришел момент когда мы стали хранить некоторые важные ключи и пароли в репозитории (в шифрованном виде конечно) и стало очевидно что «секурность» потеряет смысл если мы будем всем раздавать наш vault-password. Поэтому было решено делать push изменений на ВМ с помощью ssh-туннелей.

В начале была простая идея «все забить гвоздями» чтобы подключение производилось по предопределенным портам на сервере. И в принципе это нормально если у вас 3-5 человек, даже если 10-15. Но что если их через полгода будет 50-100? В общем-то и тут можно придумать некий «плейбучег» который будет все это обслуживать по нашей указке, но это не наш метод. Я начал думать, читать маны, гуглить.

Если заглянуть в ман (man ssh), то можно там найти следующие строки:
     -R [bind_address:]port:host:hostport
...
If the port argument is '0', the listen port will be dynamically allocated on the server and reported to the client at run time. When used together with -O forward the allocated port
will be printed to the standard output.

Т.е. ssh-сервер может динамически выделять порты, но знать о них будет только клиент. На сервере можно посмотреть список портов открытых на прослушивание (netstat/lsof), но учитывая что может быть несколько одновременных коннектов эта информация довольно бесполезна — непонятно что с чем увязывать.

Потом я случайно набрел на статью в которой автор рассказывал, что он написал патч для OpenSSH добавляющий переменную SSH_REMOTE_FORWARDING_PORTS. Переменная содержит в себе локальные порты назначенные при инициализации обратного туннеля. К сожалению патч так и не был принят. Разработчики OpenSSH очень консервативны. Судя по переписке они всячески отбрыкивались и предлагали альтернативные решения. Возможно не зря. :)
После некоторых размышлений я придумал нехитрый костыль как сообщить серверу о том какой порт он выделил. При подключении к серверу клиент может выполнять на нем команды передав их как аргумент командной строки. Этот аргумент сервер распознает как переменную SSH_ORIGINAL_COMMAND. Нам ничего не мешает создать туннель в фоновом режиме сохранить вывод который содержит порт, распарсить его выделив только порт и передать его следующей командой на сервер. А на сервере выполняется скрипт-обертка который подставляет переменную SSH_ORIGINAL_COMMAND как порт для подключения ansible-playbook.

Как это выглядит?


На клиенте (фрагмент скрипта с функцией коннекта):
ansible@client:~$ cat ssh-tunnel

#!/usr/bin/env bash

SERVER="server.example.com"
REMOTE_PORT="22"
BACKCONNECT_PORT="0"
KEY="/home/ansible/.ssh/id_rsa"
...
# Connect and update function
exec_update ()
{
        tunnel_args="-o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/%u%r@%h-%p"
        out_file="/tmp/ssh_tunnel_$USER.out"

# Check log file exists and clean
	touch $out_file
	truncate -s 0 $out_file

# Start connection
	echo "Initializing ssh backconnect to remote address: $SERVER"
	echo "Pulling updates from $SERVER"
	echo "Press ctrl+c to interrupt connection"
	ssh -f -N -T -R0:localhost:22 ansible@"$SERVER" -p"$REMOTE_PORT" -i"$KEY" $tunnel_args -E "$out_file"

# Wait for port allocation
	sleep 5
# Get the port number
	port=`awk '{print $3}' $out_file`
# Print port to stdout
	echo "Port open on $SERVER: $port"
# Connect again to initialize update proccess
	ssh $SERVER "$port"
# Close tunnel
	if ssh -T -O "exit" -o ControlPath=/tmp/%u%r@%h-%p $SERVER; then
		echo "Done"
	else
		echo "Ssh-tunnel connection can't be closed. Command failed!"
		echo "Please add folowing lines to $HOME/.ssh/config: "
		echo 'Host * '
		echo 'ControlMaster auto '
		echo 'ControlPath /tmp/%u%r@%h-%p '
		echo 'ControlPersist 600 '
		exit 1
	fi
}
...

Функция выполняется в два подхода, первый — создает перманентный туннель с мультиплексированием, второй — передает полученное значение порта на сервер и вызывает обратный коннект.  После того как скрипт отработал соединение с сервером закрывается через управляющий сокет.

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

На сервере:
ansible@server:~$ cat initial_run

#!/bin/bash

# Play vars
# Set ansible ssh-port 
REMOTE_PORT="$SSH_ORIGINAL_COMMAND"
INVENTORY="$HOME/ansible/remote"
PLAY_DIR="$HOME/ansible/playbooks"
PLAY="remote.yml"
TAGS=""

# Send notification 
notify () {
    MAILTO="admin@server.example.com"
    CLIENT=`echo $SSH_CLIENT | awk '{print $1}'`

    echo -e "<p>The system update process is started for $CLIENT</p>" | mail -a "Content-Type: text/html" -s "Notice from '$HOSTNAME': Playbook run - '$CLIENT'" $MAILTO
}

# Run playbooks with all args
run_playbooks () {
    cd $HOME/ansible

    # Run tasks
    ansible-playbook -i "$INVENTORY" "$PLAY_DIR"/"$PLAY" --tags "$TAGS" -e ansible_ssh_port="$REMOTE_PORT"
}

# Main function
main () {
    run_playbooks
}

# Do it
main "$@"


Здесь ключевой момент это получение порта к которому нужно подключаться серверу из переменной SSH_ORIGINAL_COMMAND. В принципе можно было просто назначить её для ansible_ssh_port, но я решил что для порядка стоит выделить отдельную переменную REMOTE_PORT.
Содержание плейбуков/ролей здесь уже не принципиально, хотя я добавил примеры в своем репозитории на github.com.
На этом пожалуй всё. Что с этим делать и как это может пригодиться — решать вам.

Я бы отметил пару интересных сценариев использования:
  • Динамическое выделение серверов и автоматическая их настройка (связка load-balancer/app-server);
  • Поддержка в консистентном состоянии географически разбросанных серверов к которым нет прямого доступа (разные филиалы, офисы и т.д.).

Предлагайте в комментариях свои варианты, рассказывайте о более интересных реализациях подобного функционала.

Буду благодарен если сообщите о найденных «очепятках» мне в личку.