Keywords: shell expect perl tcl python From: Mikhail E. Zakharov Newsgroups: email Date: Mon, 17 May 2005 14:31:37 +0000 (UTC) Subject: Интерактивные программы в скриптах UNIX Как ни крути, но рано или поздно становится понятно, что shell-скрипты для администрирования UNIX писать все же придется. В том числе и те, которые будут взаимодействовать с интерактивными программами такими, как telnet, ftp, su, passwd, ssh. Но тогда можно быть уверенным, что спокойной жизни администратора наступил конец, поскольку интерактивность программ таит в себе множество подводных камней, которые не встречаются в программировании рядовых shell-скриптов. Хотя к счастью, или нет, большинство этих проблем традиционно проявляются в течение первых пяти минут и выглядят для автора скрита как невозможность пройти аутентификацию на сервере. И поначалу это обстоятельство ставит в тупик, поскольку привычные конвейерные конструкции вида: $ echo luser && echo TopSecret | telnet foo.bar.com вроде бы срабатывать отказываются и в результате простая, на первый взгляд, проблема приобретает статус невыполнимого задания. Однако, не все потеряно, и если не вдаваться в суть проблемы, хотя мы это чуть позже обязательно сделаем, простое решение появится довольно быстро, ведь многие из диалоговых программ имеют встроенные механизмы обработки скриптов, как например, стандартный ftp-клиент FreeBSD: $ echo '$FILEPUT' | ftp -N ftprc luser@foo.bar.com Эта команда заставит ftp подключиться к хосту foo.bar.com под именем пользователя luser и выполнить макрос FILEPUT (закачка файла на сервер) описанный в файле ftprc. В этом же файле кроме макроса, должны быть обязательно описаны хост, логин и пароль пользователя на этом хосте: $ cat ftprc machine foo.bar.com login luser password TopSecret macdef FILEPUT binary cd /tmp put some_usefull_file.bin bye <-- Внимание! Здесь обязательный перевод строки в конце макроса. $ Если же диалоговая программа по какой-то причине не поддерживает встроенные скрипты, то среди свободно-распространяемого ПО всегда найдется ее аналог, в котором уже давно реализована возможность выполнять действия автоматически. В самом деле, вы же не первый столкнулись с подобной задачей! Другой способ заставить диалоговую программу выполнить что-либо без участия пользователя, это перенаправить ее стандартный ввод. Например, так в сильно упрощенном виде выглядит скрипт загрузки Oracle: #!/bin/sh su - oracle -с /oracle/bin/svrmgrl <spawn("telnet foo.bar.com"); $exp->expect($timeout, [ 'ogin: $' => sub { $exp->send("luser\n"); exp_continue; } ], [ 'assword:$' => sub { $exp->send("TopSecret\n"); exp_continue; } ], '-re', qr'[#>:] $' ); $exp->send("who am i\n"); $exp->send("exit\n"); $exp->soft_close(); Если же я ошибся, в вашей приверженности языку Perl, у вас всегда есть возможность использовать Python для которого написан соответствующий модуль pexpect (http://pexpect.sourceforge.net). Язык Python, естественно, уже должен быть проинсталлирован в системе заранее. Если нет, порты FreeBSD нас снова выручат: # cd /usr/ports/lang/python # make install clean И, соответственно, для модуля pexpect: # cd /usr/ports/misc/py-pexpect # make install clean Скрипт telnet-сессии на Python будет таким: #!/usr/local/bin/python import pexpect child = pexpect.spawn('telnet foo.bar.com'); child.expect('ogin: '); child.sendline('luser'); child.expect('assword:'); child.sendline('TopSecret'); child.sendline('who am i'); child.sendline('exit'); child.expect(pexpect.EOF); print child.before; Конечно, если и Python вас по какой-то причине не устраивает, тогда вы можете установить, например, PHP. Ну, в общем, вы поняли, поиск удобных вариантов решений продолжать можно довольно долго, и разве что только Visual Basic'а в этом списке не будет. Поэтому думаю, настало время, интересное это занятие отложить в сторону, и наконец, попытаться понять скрытую суть вещей. Суть вещей ---------- Итак, при запуске интерактивной программы из shell-скрипта на самом деле происходит следующее... И хотя эта фраза звучит в стиле заключительной речи сыщика Пуаро, до конца повествования на самом деле еще довольно далеко и имеет смыс начать издалека, т.е. с самого начала. А потому, просто необходимо забыть все те красивости, которые предлагаются expect'ом или его, в высшей степени, достойными клонами. Для начала попытаемся с имитировать некое грубое подобие expect-like программы при помощи скриптов shell с использованием всего арсенала стандартных утилит *NIX-системы и попытаемся таки хоть отчасти уловить то самое, что указано в заголовке раздела. Важно, также отметить, что все эксперименты справедливы для FreeBSD и нет никаких гарантий, что они дадут те же результаты и на других операционных системах. Чтобы максимально упростить нашу задачу, и не бороться с конвейерами, упомянутыми в первых примерах в самом начале статьи, создадим два fifo-файла, один для стандартного ввода (in.fifo), другой для вывода (out.fifo), и бог с ним пока со стандартным потоком ошибок: $ mkfifo in.fifo $ mkfifo out.fifo Далее, запустим неудачный в плане безопасности, и, из-за примеров на expect, perl и python, досмерти надоевший telnet с перенаправлениями потоков ввода-вывода на in.fifo и out.fifo: $ telnet -K localhost > out.fifo < in.fifo Маленький шаг в сторону: поскольку все эксперименты проводятся на FreeBSD, то при попытке подключения к telnet-серверу, тоже загруженному на FreeBSD, автоматически включаются механизмы (SRA), призванные обеспечить безопасность telnet-сессии: $ telnet localhost Trying ::1... Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. Trying SRA secure login: User (luser): <-- ожидание ввода логина (строка не переведена) Чтобы отключить такое поведение и вернуть telnet'у традиционный вид, будем загружать telnet с ключем -K: $ telnet -K localhost Trying ::1... Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. FreeBSD/i386 (unity) (ttyp1) login: <-- ожидание ввода логина (строка не переведена) Итак, пусть наш первый скрипт test_1.sh пока состоит из следующих строк: #!/bin/sh mkfifo in.fifo mkfifo out.fifo telnet -K localhost > out.fifo < in.fifo Теперь запустив скрипт на исполнение: $ ./test_1.sh & можно начинать проводить эксперименты. Обратите внимание на параметр &, переводящий скрипт в фоновое выполнение команды, это сделано для того, чтобы иметь возможность продолжать работу с тем же терминалом во время наших опытов. Например, попробуем прочитать что-либо из out.fifo и написать что-либо в in.fifo. $ cat out.fifo & Параметр здесь & указан по той же самой причине, что и в случае с вызовом скрипта. Правда в результате выполнения команды cat на экране, к сожалению, ничего не появится. Видимо, это происходит из-за блокировок чтения/записи при работе fifo-файлов: запись в fifo-файл блокируется пока из него никто не читает, и чтение из fifo-файла тоже блокируется, пока другая сторона не готова в него писать. Вероятно, весь процесс тормозит наш in.fifo, в который ничего не записано. Проверим эту догадку послав перевод строки в input-канал: $ echo > in.fifo Была ли догадка правильной, или истинная причина скрывается в чем-то другом, но чудо таки произошло! На терминале появились долгожданные результаты работы команды cat out.fifo: $ Trying ::1... Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. FreeBSD/i386 (unity) (ttyp1) login: login: Единственное, что смущает, так это дважды повторяющиеся login:. На самом деле, это естественная реакция telnet-сервера на то, что выше командой: echo > in.fifo ему был послан перевод строки. Сделав необходимый вывод, чтобы избежать отсылки перевода строки, заменим команду echo > in.fifo на: $ echo -n > in.fifo А еще лучше, будет использовать комбинацию: $ cat > in.fifo & Это должно в будущем предотвращать закрытие канала ввода, что справедливо воспринимается telnet'ом, как разрыв связи. Чтож, теперь можно продолжать наши изыскания, но для начала следует избавиться от background-процессов, которые появились в результате использования &: $ fg ./test_1.sh ^C[2] + Done cat out.fifo Команда fg и затем нажатие ^C на клавиатуре для того, чтобы прервать работу процесса. Итак, следующим шагом будет попытка отфильтровать выходной поток (out.fifo), чтобы исходя из заданного шаблона "отловить" необходимые данные чтобы реализовать основную комбинацию команд expect: expect request {send answer} Сама собой напрашивается какая-нибудь такая связка: $ cat out.fifo | grep request && echo answer Однако, в нашем случае цепочка не будет правильно срабатывать уже на этапе grep. Это объясняется тем, что grep предназначен для распечатки строк, соответствующих указанному шаблону, и поэтому совершенно естественно, что перед началом сравнения данного шаблона с каждой новой строкой, grep всегда ожидает окончания ввода этой строки (\n) или же окончания файла (\0). В нашем случае эта особенность делает невозможным перехват login: при помощи grep поскольку за login: не следует перевод строки (система ожидает от пользователя ввод логина), этот момент выше отражен на примере telnet -K localost. Таким образом grep будет вечно (до истечения таймаута у telnet-сервера) ожидать получения конца строки, а затем увидит сразу конец файла. Необходим другой механизм обработки таких незаконченных строк. Как вариант, возможно вместо cat использовать команду dd, которая в цикле будет посимвольно передавать данные grep'у. Я имею ввиду следующую конструкцию, которая показана на примере уже работающего скрипта expect.sh: #!/bin/sh while :; do dd if=out.fifo bs=1b count=1 2>/dev/null | grep $1 if [ $? -eq 0 ]; then # Match found echo "$2" > in.fifo exit 0 fi # Match not found, let's play again done Запускать скрипт можно следующим образом (файлы in.fifo и out.fifo уже должны быть созданы): $ ./expect.sh "request" "answer" Чтож, настало время собрать воедино в одном скрипте (test_2.sh) весь полученный опыт и попытаться автоматизировать нашу традиционную telnet-сессию: #!/bin/sh mkfifo out.fifo in.fifo telnet -K localhost 1> out.fifo 0< in.fifo & cat > in.fifo & cat out.fifo > out.fifo & pid=`jobid` ./expect.sh "ogin" "zmey" ./expect.sh "word" "SaracSh" sleep 1 echo 'who am i > /tmp/test.txt' > in.fifo sleep 1 echo "exit" > in.fifo rm out.fifo in.fifo kill $pid Запустив этот скрипт на выполнение, если все удачно, получится следующий незамысловатый вывод: $ ./test_2.sh login: Password: Connection closed by foreign host. Зато в качестве боевого трофея останется файл /tmp/test.txt, который будет подтверждать успешность эксперимента: $ cat /tmp/test.txt luser ttyp3 May 10 16:39 (localhost) Если же что-то пошло неудачно, вероятно придется воспользоваться командой kill для каждого процесса, оставшегося от провалившегося опыта. К сожалению, скрипт этот работает крайне нестабильно: он сильно зависит от величин параметра sleep и скорости ответа telnet-сервера из-за чего не всегда данные приходят вовремя, что ведет к зависаниям и процессы приходится часто убивать. Ясно также, что такая конструкция работает не везде, например в Linux мне не удалось добиться хоть сколько-нибудь приемлемых результатов, но если это интересно, путь любители Linux'а этим и занимаются. Надо идти дальше ---------------- и найти утилиту, решил я, которую можно было бы без всяких надстроек типа TCL, Perl, Python использовать в качестве expect-заменителя для чистого shell. Ясно, что написана она должна быть на С и быть портирована под возможно большее количество операционных систем. Чтож, let's Google! И через некоторое время такая программа нашлась, это оказалась pty-4.0 написанная Daniel J. Bernstein в 1992 году. Однако, после этого релиза, программа, судя по всему, больше не развивалась. Через некоторое время исходный код pty-4.0 у меня даже собрался в бинарный, и некоторые части pty-4.0 стали запускаться. Но к этому моменту стало очевидно, что проще написать свою программу, чем разбираться со старой. А почему бы и не написать? И я углубился в более серьезное изучение вопроса. Довольно быстро я узнал, что, действительно, наиболее удобный способ взаимодействия с интерактивными программами, это именно имитация для них терминала. И место псевдо-терминалов в структуре будущей программы четко определилось несмотря даже на сбивчивые бубнения специалистов из интернета насчет expet'а и pty-сессий. Стало также понятно, что метод запуска приложений под контролем pty-сессий очень удобно выполнять внутри какой-либо оболочки, например, TCL, Perl и пр. Однако для тех же целей, ничего не мешает использовать программу на С и чистый интерпретатор sh. В результате всего этого, уже через пару недель у меня была готова рабочая версия empty (http://www.sourceforge.net/projects/empty), которая позволяет запускать интерактивные программы и вести с ними диалог посредством fifo-файлов. Например, надоевшая FreeBSD сессия telnet в варианте shell-скрипта для empty будет выглядеть так: #!/bin/sh empty -f -i in.fifo -o out.fifo telnet -K localhost empty -w -i out.fifo -o in.fifo -t 5 "ogin:" "luser" empty -w -i out.fifo -o in.fifo -t 5 "assword:" "TopSecret" empty -s -o in.fifo 'who am i > /tmp/test.txt' empty -s -o in.fifo 'exit' Чтож, это гораздо короче, чем кривоватый скрипт test_2.sh, да и работает утилита вполне устойчиво на *BSD, Linux и Solaris не требуя при этом наличия TCL, Perl или Python. Правда, функций в программе пока меньше, чем у ее взрослых аналогов, но возможно, все еще впереди.