angle-left

О терминалах, их драйверах и CLI в Perl

Доброе утро!

Для решения некоторых задач при разработке CLI возникает необходимость работать с пользовательским вводом «слегка по-другому». К примеру, считать пароль, не печатая символы на экран. В данной статье я хочу объяснить, как это достигается.

Терминал

Для начала немного истории.

Так сложилось, что пользователи взаимодействовали с UNIX-системами через устройства, называемые «терминалами». Терминал преставлял из себя монитор на основе ЭЛТ, позволяющий выводить текст с разрешением в восемьдесять строк и двадцать четыре столбца, а также некоторое устройство ввода (клавиатура). Подсоединялись терминалы через последовательный порт (RS-232).

Сейчас типовой интерфейс к UNIX для пользователя — это X Window System и эмуляторы терминала (такие как xterm), предоставляющие доступы к псевдо-терминалам — устройствам, дающим интерфейсы терминала для пользователя и приложений, их требующих.

Терминалы и псевдо-терминалы ассоциируются с драйвером терминала, который отвечает за ввод и вывод. Во время ввода, драйвер терминала может работать в двух режимах: каноническом и неканоническом (canonical mode и noncanonical mode).

В каноническом режиме драйвер буферизирует пользовательский ввод до перевода строки (то есть, пока пользователь не нажмёт Enter). После этого, программе, вызвавшей read() для стандартного ввода вернётся целая строка, завершённая символом перевода строки. В случае, если было запрошено меньше байт, чем пользователь ввёл при наборе строки, то остаток можно будет считать при следующем вызове read().

В неканоническом режиме ввод происходит посимвольно, а не построчно. Это необходимо программам с более сложным CLI, чем просто читающим строки. К примеру, работая в таком режиме, bash получает возможность обработать нажатие TAB и попытаться произвести автодополнение, что было бы невозможно, ожидай он целой строки.

Ко всему прочему, в каноническом режиме драйвер терминала обрабатывает множество специальных символов, таких ^C (Control-C), ^S, ^Q, ^Z и другие. Данная функциональность тоже может быть отключена при переводе драйвера в неканонический режим.

Атрибуты терминала

У терминала есть набор атрибутов, которые можно получить и поменять. Для этого используется соответствующая пара функций (из termios(3))):

       int tcgetattr(int fd, struct termios *termios_p);

       int tcsetattr(int fd, int optional_actions,
                     const struct termios *termios_p);

Здесь fd — это номер файлового дескриптора, связанного с терминалом, а атрибуты помещаются/берутся из структуры termios, имеющей следующий вид:

           tcflag_t c_iflag;      /* input modes */
           tcflag_t c_oflag;      /* output modes */
           tcflag_t c_cflag;      /* control modes */
           tcflag_t c_lflag;      /* local modes */
           cc_t     c_cc[NCCS];   /* special characters */

Первые четыре поля являются битовыми масками, константы, используемые в которых можно увидеть в указанной выше man-странице. Поле c_cc определяет особые символы, обрабатываемые терминалом.

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

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

 1  void init_term() {                                                             
 2          struct termios tty;                                                    
 3                                                                                 
 4          if (term_initialized) return;                                          
 5          if (!isatty(STDIN_FILENO)) {                                     
 6                  // Реагировать на ошибку                                
 7          }                                                                      
 8          tcgetattr(STDIN_FILENO, &savetty);                   
 9          tty = savetty;                                                         
 10         tty.c_lflag &= ~(ICANON|ECHO|ISIG);                                    
 11         tty.c_cc[VMIN] = 1;                                                    
 12         tty.c_cc[VTIME] = 0;                                                   
 13         tcsetattr(STDIN_FILENO, TCSAFLUSH, &tty);     
 14         term_initialized = true;                                               
 15 }    

Строками 4 и 14 позволим себе вызывать нашу функцию в разных условиях, в том числе повторно. В строках 5-7 проверяем, что стандартный ввод — терминал. В противном случае нельзя настраивать терминал, значит, надо либо завершиться с ошибкой, либо, в случае shell, начать работу в неинтерактивном режиме. Строкой 8 сохраняем атрибуты в глобальную переменную, где-то объявленную. Строки 9-12 выставляют нужные нам атрибуты.

ICANON — канонический режим, сбрасываем, чтобы читать посимвольно, а не строками.

ECHO — вывод вводимых символов на экран. Сбрасываем, чтобы иметь возможность обрабатывать, к примеру, навигацию стрелочками, не выводя на экран вещей в духе ^[[D.

ISIG — посылать сигналы при нажатии на соответствующий кнопки пользователем. Сбрасываем, чтобы уметь обрабатывать ^C, ^D и другие комбинации.

VMIN параметризует минимальное число символов, которое программа сможет считать во время read(), в данном случае один.

VTIME — время, которое будет производиться ожидание ввода от пользователя. Ноль значит ограничения нет.

Дополнительная опция TCSAFLUSH позволяет сбросить состояние очередей.

Краткое отступление касательно очередей. В драйвере терминала есть две очереди — вводимых символов и выводимых символов. Соответственно, когда процесс выводит что-то на терминал, его вывод попадает в очередь вывода, а когда пользователь что-то печатает в терминале, его ввод попадает в очередь ввода. Если включен упомянутый режим ECHO, то вводимые символы будут дублироваться в очередь вывода, чего мы и пытаемся избежать его выключением.

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

 1 void reset_term() {                                                            
 2         if (!term_initialized) return;                                         
 3         tcsetattr(STDIN_FILENO, TCSAFLUSH, &savetty);                        
 4         term_initialized = false;                                              
 5 }

Если вспомнить, что такое savetty, то всё должно быть очевидно.

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

Со многими другими флагами и атрибутами можно ознакомиться в terminfo(3).

Настройка терминала в Perl

Пришло время взглянуть, как это всё отображается на Perl. Для Perl существует модуль Term::ReadKey (документация), который позволяет задать атрибуты терминалу. Задаются они комплексно, путём указания режима. Да, существуют, можно сказать, «режимы поверх режимов». Это Cooked, Cbreak и Raw modes. Сравним:

  • cooked: построчный ввод, редактирование строки, генерация сигналов по символам, Xon/Xoff, особые символы, echo;
  • cbreak: посимвольный ввод, нет редактирования, есть генерация сигналов, Xon/Xoff, echo по усмотрению приложения;
  • raw: посимвольный ввод, нет редактирования, генерации сигналов, Xon/Xoff и echo.

Режим при использовании Term::ReadKey устанавливается вызовом ReadMode, принимающим имя режима и, опционально, файловый дескриптор. Перечисленные режимы, соответственно, называются 'normal', 'cbreak' и 'ultra-raw'. Кроме того, доступны менее общепринятые конфигурации, такие как 'noecho' (normal с выключенным ECHO) и 'raw' (ultra-raw, но занимающийся конвертацией переводов строк). Помимо названного, можно восстановить оригинальный режим, передав в функцию 'restore'.

После того, как выставили режим, можно воспользоваться функцией ReadKey, принимающей первым аргументом режим, а вторым файловый дескриптор, тоже опционально. Но нет, это не тот же режим, что и в ReadMode. В обоих случаях он тоже называется MODE, но в ReadKey это число, определяющее, будет ли чтение производиться с таймаутом (положительное число), без него (ноль) или неблокирующе (-1).

Аналогичное актуально для ReadLine, отличие в работе этих функций очевидно из названия.

Помимо указанных, Term::ReadKey содержит функции для контроля разрешения терминала, скорости работы и отношения между контрольными символами и комбинациями клавиш, нажимаемыми для их ввода.

Пользуясь полученными знаниями, напишем программу, которая будет ходить по SSH на разные хосты и предлагать убивать на них джаву:

#!/usr/bin/perl
# Made by kk

use Cwd;
use Net::OpenSSH;
use strict;
use Term::ReadKey;
use warnings;

die "Usage: $0 <host...>" unless @ARGV > 0;

for (@ARGV) {
  my $user;
  $user = $1 and $_ = $2 if /^(.+)@(.+)$/;
  my $port;
  $port = $2 and $_ = $1 if /^(.+):(.+)$/;
  $port //= 22;
  my $host = $_;

  print "\nusername for $host:$port> "
    and $user = ReadLine 0 unless defined $user;
  chomp $user;

  print "\npassword for $user\@$host:$port> ";
  ReadMode 'noecho';
  my $pass = ReadLine 0;
  chomp $pass;
  ReadMode 'restore';

  my $ssh = Net::OpenSSH->new($host, user => $user, password => $pass, port => $port);
  die "\nError during connection: " . $ssh->error . "\n" if $ssh->error;

  print "\nRunning 'ps -ef | grep java' to find java processes\n";
  my $ps = $ssh->capture('ps -ef | grep java');
  die "\nError lookup java process: " . $ssh->error . "\n" if $ssh->error;
  print $ps;
  print "\nWho shall be SIGINTed? PID(s) (leave empty to do nothing) []: ";
  my $pid = ReadLine 0;
  chomp $pid;
  next if $pid =~ /^\s*$/;
  my @pids = split / +/, $pid;

  $ssh->system("kill -INT $_")
    or warn "\nError when killing $_ " . $ssh->error . "\n" for @pids;
}

Здесь мы перевели выключили ECHO у терминала и вырезали перевод строки в конце. Альтернативный вариант — читать посимвольно, пока перевод строки не встретим.

Заключение

Сегодня мы познакомились с концепцией терминала и драйвера терминала, увидели, как с этим работать из C, а также попробовали воспользоваться перловым модулем, предоставляющим доступ к этой функциональности, став чуть лучше в написании программ с интерактивным CLI.