Автоматизируем работу в интерактивных консольных программах используя expect


В жизни сетевого инженера (да и не только), наступает такой момент, когда некоторые рутинные операции надоедает выполнять, и хочется их оптимизировать. В один прекрасный день я понял, что каждый раз когда мне нужно авторизоваться на коммутаторе, то набирать логин\пароль, а затем ещё пароль на enable мне надоело. Поэтому данное действие было решено как-то оптимизировать. Взяв бутылочку пенного я сел за «работу»..

В компании где я работаю, по определённым обстоятельствам авторизация на коммутаторах\роутерах\DSLAM и прочем оборудовании происходит не по ssh, а по telnet. Средствами самого telnet возможности передать логин\пароль нет, поэтому поиск с попутным распитием алкоголя продолжился, и остановился на утилите expect.

Expect — это утилита, которая парсит потоковый вывод консольных программ, и в ответ на них отправляет какой либо заранее предусмотренный «ответ». Например, при подключении к ftp серверу, ожидаем получить запрос на ввод пароля, и при его получении — отправляем его.

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

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

#!/usr/bin/expect -f

spawn ftp example.com

expect {
    "Name:" {
        send "testuser\n"
        expect "Password:"
        send "testpass\n"
        expect "ftp>"
        send "passive\n"
    }
}

interact

В первой строке мы записываем sha-bang, которым указывает командной оболочке, что этот скрипт следует интерпретировать как expect-script (запустить expect и передать ему скрипт). Затем используя spawn вызываем программу ftp и передаём ей в качестве аргумента адрес хоста, к которому хотим подключиться. И вот самое интересное — блок expect {}. В нём мы сообщаем интерпретатору, что ожидаем «Name: «, и в ответ с помощью send отправляем логин и символ перевода каретки \n (Enter). Затем ожидаем получить запрос на ввод пароля, отправляем пароль и переводим ftp-клиент в пассивный режим (send "passive\n"). В самом конце у нас interact который указывает, что необходимо по завершении сценария передать управление пользователю.

Если сильно раздражает вывод программ во время выполнения скрипта, то можно отключить это добавив log_user 0.

Если что-то во время выполнения скрипта идёт не так, то можно посмотреть более подробно, какие данные получает expect, нашёл ли совпадения, и что посылает в ответ. Для этого надо добавить exp_internal 1 в код скрипта:

#!/usr/bin/expect -f

spawn ftp example.com
exp_internal 1

expect {
    "*Name*" {
        send "testuser\n"
        expect "Password:"
        send "testpass\n"
        expect "ftp>"
        send "passive\n"
    }
}

interact

Выполним скрипт:

spawn ftp example.com

expect: does "" (spawn_id exp6) match glob pattern "*Name*"? no
Connected to example.com.

expect: does "Connected to example.com.\r\n" (spawn_id exp6) match glob pattern "*Name*"? no
220 (vsFTPd 2.3.2)
Name (example.com:testuser): 
expect: does "Connected to example.com.\r\n220 (vsFTPd 2.3.2)\r\nName (example.com:testuser): " (spawn_id exp6) match glob pattern "*Name*"? yes
expect: set expect_out(0,string) "Connected to example.com.\r\n220 (vsFTPd 2.3.2)\r\nName (example.com:testuser): "
expect: set expect_out(spawn_id) "exp6"
expect: set expect_out(buffer) "Connected to example.com.\r\n220 (vsFTPd 2.3.2)\r\nName (example.com:testuser): "
send: sending "testuser\n" to { exp6 }

expect: does "" (spawn_id exp6) match glob pattern "Password:"? no
testuser

expect: does "testuser\r\n" (spawn_id exp6) match glob pattern "Password:"? no
331 Please specify the password.
Password:
expect: does "testuser\r\n331 Please specify the password.\r\nPassword:" (spawn_id exp6) match glob pattern "Password:"? yes
expect: set expect_out(0,string) "Password:"
expect: set expect_out(spawn_id) "exp6"
expect: set expect_out(buffer) "testuser\r\n331 Please specify the password.\r\nPassword:"
send: sending "testpass\n" to { exp6 }

expect: does "" (spawn_id exp6) match glob pattern "ftp>"? no


expect: does "\r\n" (spawn_id exp6) match glob pattern "ftp>"? no
530 Login incorrect.
Login failed.

expect: does "\r\n530 Login incorrect.\r\nLogin failed.\r\n" (spawn_id exp6) match glob pattern "ftp>"? no
ftp> 
expect: does "\r\n530 Login incorrect.\r\nLogin failed.\r\nftp> " (spawn_id exp6) match glob pattern "ftp>"? yes
expect: set expect_out(0,string) "ftp>"
expect: set expect_out(spawn_id) "exp6"
expect: set expect_out(buffer) "\r\n530 Login incorrect.\r\nLogin failed.\r\nftp>"
send: sending "passive\n" to { exp6 }
tty_raw_noecho: was raw = 0  echo = 1
spawn id exp6 sent  >
passive
Passive mode on.
ftp> 

Видно, что после отправки пароля, ftp сервер снова его спрашивает. Значит, ошиблись где-то в логине или пароле.

Теперь пример сложнее. Скрипт выше делает то, что нам нужно в данном случае, но в нём есть некоторые недостатки: для каждого хоста необходимо создавать новый скрипт; если сайт недоступен, то скрипт будет вести себя весьма странно.
Исправим эти недостатки немного модифицировав его (для удобства добавленные\изменённые участки кода прокомментированы):

#!/usr/bin/expect -f

if {[llength $argv] != 1} { # проверяем количество переданных аргументов скрипту
    send_user  "This script requires an argument: ftp host to connect"
    exit 1 # если аргументов нет - завершаем выполнение скрипта с ошибкой
}

set HOST [lindex $argv 0] # в переменную HOST записываем первый аргумент - адрес хоста
spawn ftp $HOST # запускаем ftp и передаём аргумент $HOST
set timeout 3 # устанавливаем время ожидания expect в 3 секунды
set USERNAME "testuser" # имя пользователя и пароль так же вынесли в переменные для удобства
set PASSWORD "testpass"

expect {
    "220 (vsFTPd ?????)" { # сейчас ожидаем строки с приветствием ftp сервера
        expect "Name:"
        send "$USERNAME\n"
        expect "Password:"
        send "$PASSWORD\n"
        expect "ftp>"
        send "passive\n"
    } timeout { # выжидаем timeout
        send_user "Unnable connect to $HOST"
        exit 1
        }
}

interact

Внимательный читатель наверняка заметил, что в приветствии ftp сервера вместо версии — вопросительные знаки. Дело в том, что expect может проверять соответствия используя регулярные выражения, или (по-умолчанию) на основе wildcards. В данном случае неизвестные цифры версии ftp сервера мы заменили на «?».
Далее вместо строки для поиска совпадений мы указываем интерпретатору, что если совпадений не нашли, ждём определённый промежуток времени указанный в set timeout, выводим сообщение об ошибке и завершаем выполнение скрипта.
В принципе, на этом стоит закончить с данным примером, но можно добавить ещё один интересный штрих: убрать лишний вывод. В данном примере, если expect не нашёл совпадений, наше сообщение об ошибке будет трудно заметить на фоне остального вывода. Отключить его можно добавив log_user 0. В любом нужном месте его можно включить заменив 0 на 1. Всё что отправлено с помощью send_user будет по-прежнему выводиться.

Всё бы хорошо, но всё ещё чего-то не хватает скрипту, что-то не так… И правда: проверка таким образом доступности ftp сервера не самая лучшая идея.
Хороший выход из данной ситуации — это нужную нам часть вынести в bash скрипт, проверку доступности выполнять обычным пингом, а количество аргументов средствами самого bash:

#!/bin/bash

# Проверяем количество аргументов
if [ ! "$#" -eq 1 ]
then
        echo "1 arguments required, $# provided"
        exit 1
fi

# Проверяем доступность хоста
if ( ! ping -c1 -i1 -n -s10 -W1 $1  &>/dev/null )
then
        echo "Host $1 not available"
        exit 1
fi


USERNAME="testuser"
PASSWORD="testpass"

/usr/bin/expect<<EOF
    spawn ftp $1
    expect "Name*"
    send "$USERNAME\n"
    expect "Password:"
    send "$PASSWORD\n"
    expect "ftp>"
    send "passive\n"
    expect eof
EOF

Комментировать часть скрипта написанного на bash я не буду — для этого есть ресурсы лучше. Какой либо особой магии здесь нет. Просто проверяем количество аргументов переданных скрипту, доступность сервера для подключения и если всё в порядке — запускаем expect передавая ему команды. expect eof здесь нужен чтобы скрипт не завершался сразу после выполнения.

Раз уж мы заговорили про использование expect в bash, то коснёмся и сбора данных. Для примера давайте представим, что есть сферический ftp сервер в вакууме, с которого зачем-то нужно раз в сутки собирать список имеющихся директорий. Ситуация надуманная, но для примера подойдёт:

#!/bin/bash                                                                                                   
                                                                                                              
if [ ! "$#" -eq 1 ]                                                                                           
then                                                                                                          
        echo "1 arguments required, $# provided"                                                              
        exit 1                                                                                                
fi                                                                                                            
                                                                                                              
if ( ! ping -c1 -i1 -n -s10 -W1 $1  &>/dev/null )                                                             
then                                                                                                          
        echo "Host $1 not available"                                                                          
        exit 1                                                                                                
fi                                                                                                            
                                                                                                              
                                                                                                              
USERNAME="testuser"                                                                                              
PASSWORD="testpass"                                                                                           
                                                                                                              
DIRLIST=$(expect -c "                                                                                         
    set timeout 3                                                                                             
    spawn ftp $1                                                                                              
    expect \"Name*\"                                                                                          
    send \"$USERNAME\n\"                                                                                      
    expect \"?assword:\"                                                                                      
    send \"$PASSWORD\n\"                                                                                      
    expect \"ftp>\"                                                                                           
    send \"passive\n\"                                                                                        
    expect \"ftp>\"                                                                                           
    send \"ls\n\"                                                                                             
    expect \"ftp>\"                                                                                           
    send \"bye\n\"                                                                                            
")                                                                                                            
                                                                                                        
echo "$DIRLIST" | sed -e '1,14d' | head -n -2 > $1_dirlist.txt

Пример принципиально не сильно отличается от предыдущего. В переменной DIRLIST запускаем expect и построчно выполняем скрипт. Следует обратить внимание, что так как мы запустили expect внутри bash, то надо дополнительно экранировать посылаемые и ожидаемые данные. Далее работаем с полученными результатами как с простым текстом.

Для моих нужд в результате получился такой скрипт:

#!/bin/bash

username="username"
password="password"
enpassword="enablepassword"
enpassword2="enablepassword2"

# IP-адрес берём из буфера обмена
ip=$(xsel -p)
re="^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$"

# Убираем всё ненужное от ip-адреса
ip=`echo $ip | sed -e 's/^[ \t]+|[ \t]+$//g'`
ip=`echo $ip | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}'`

# проверяем правильный ли ip-адрес это
if [[ ! $ip =~ $re ]] || [[ "${#ip}" -ge 16 ]] || [[ "${#ip}" -eq 0 ]]; then
    notify-send "Telnet" "Error while extracting IP address" \
                                                -u normal \
                                                -i gtk-dialog-warning \
                                                -t 8000
    exit 0;
fi

# проверяем доступность хоста
if ( ! fping -c1 -t500 $ip &>/dev/null ); then
    notify-send "Telnet" "Host $ip not available. Check connection." \
                                                -u normal \
                                                -i gtk-dialog-warning \
                                                -t 8000
    exit 1
fi

# В этой переменной храним собственно сам скрипт
commands="
spawn telnet $ip
expect {
    # Cisco
    \"User Access Verification\" {
        send \"$username\n\"
        expect \"Password: \"
        send \"$password\n\"
        send \"enable\n\"
        send \"$enpassword\"
    }

    # Some other switch\router
    \"Station's information:\" {
        send \"$username\n\"
        expect \"PassWord:\"
        send \"$password\n\"
        send \"enable\n\"
        send \"$enpassword2\n\"
    }

    # and so on...
}
interact
"

gnome-terminal --geometry 120x30+30+20 \
               --title "Telnet to $ip" \
               --execute /usr/bin/expect -c "$commands"

Доступность хоста проверяется с помощью fping, т.к. он позволяет задать меньший timeout ответа от хоста. После всех необходимых действий с входными данными, скрипт запускает gnome-terminal, в нём интерпретатор expect которому передаётся скрипт в виде переменной commands.

Страница программы — http://expect.sourceforge.net. Там же есть небольшой список how-to и статей.