Pull to refresh

Настройка ssh callback на сервере в связке с Ansible

Reading time 7 min
Views 14K
Всем известно, что с помощью 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);
  • Поддержка в консистентном состоянии географически разбросанных серверов к которым нет прямого доступа (разные филиалы, офисы и т.д.).

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

Буду благодарен если сообщите о найденных «очепятках» мне в личку.
Tags:
Hubs:
+12
Comments 0
Comments Leave a comment

Articles