package Net::SSH::Expect;
use 5.008000;
use warnings;
use strict;
use fields qw(
host user password port no_terminal escape_char ssh_option
raw_pty exp_internal exp_debug log_file log_stdout restart_timeout_upon_receive
timeout terminator expect debug next_line before match after binary
);
use Expect;
use Carp;
use POSIX qw(:signal_h WNOHANG);
our $VERSION = '1.09';
# error contants
use constant ILLEGAL_STATE => "IllegalState";
use constant ILLEGAL_STATE_NO_SSH_CONNECTION => "IllegalState: you don't have a valid SSH connection to the server";
use constant ILLEGAL_ARGUMENT => "IllegalArgument";
use constant SSH_AUTHENTICATION_ERROR => "SSHAuthenticationError";
use constant SSH_PROCESS_ERROR => "SSHProcessError";
use constant SSH_CONNECTION_ERROR => "SSHConnectionError";
use constant SSH_CONNECTION_ABORTED => "SSHConnectionAborted";
sub new {
my $type = shift;
my %args = @_;
my Net::SSH::Expect $self = fields::new(ref $type || $type);
# Options used to configure the SSH command
$self->{host} = $args{host}|| undef;
$self->{user} = $args{user} || $ENV{'USER'};
$self->{password} = $args{password} || undef;
$self->{port} = $args{port} || undef; # ssh -p
$self->{no_terminal} = $args{no_terminal} || 0; # ssh -T
$self->{escape_char} = $args{escape_char} || undef; # ssh -e
$self->{ssh_option} = $args{ssh_option} || undef; # arbitrary ssh options
$self->{binary} = $args{binary} || "ssh"; # path to SSH binary.
# Options used to configure the Expect object
$self->{raw_pty} = $args{raw_pty} || 0;
$self->{exp_internal} = $args{exp_internal} || 0;
$self->{exp_debug} = $args{exp_debug} || 0;
$self->{log_file} = $args{log_file} || undef;
$self->{log_stdout} = $args{log_stdout} || 0;
$self->{restart_timeout_upon_receive} = $args{restart_timeout_upon_receive} || 0;
# Attributes for this module
$self->timeout(defined $args{timeout} ? $args{timeout} : 1);
$self->{terminator} = $args{terminator} || "\n";
$self->{next_line} = "";
$self->{expect} = undef; # this will hold the Expect instance
$self->{debug} = $args{debug} || 0;
$self->{before} = "";
$self->{match} = "";
$self->{after} = "";
# validating the user input
foreach my $key (keys %args) {
if (! exists $self->{$key} ) {
croak ILLEGAL_ARGUMENT . " attribute '$key' is not a valid constructor argument.";
}
}
return $self;
}
# boolean run_ssh() - forks the ssh client process opening an ssh connection to the SSH server.
#
# This method has three roles:
# 1) Instantiate a new Expect object configuring it with all the defaults and user-defined
# settings.
# 2) Define the ssh command line using the defaults and user-defined settings
# 3) Fork the ssh process using the spawn() method of the Expect instance we created.
# The SSH connection is established on this step using the user account set in the 'user'
# constructor attribute. No password is sent here, that happens only in the login() method.
#
# This method is run internally by the login() method so you don't need to run it yourself
# in most of the cases. You'll run this method alone if you had set up public-key authentication
# between the ssh client and the ssh server. In this case you only need to call this method
# to have an authenticated ssh connection, you won't call login(). Note that when you
# use public-key authentication you won't need to set the 'password' constructor attribute
# but you still need to define the 'user' attribute.
# If you don't know how to setup public-key authentication there's a good guide at
# http://sial.org/howto/openssh/publickey-auth/
#
# returns:
# boolean: 1 if the ssh ran OK or 0 otherwise. In case of failures, use $! to do get info.
sub run_ssh {
my Net::SSH::Expect $self = shift;
my $user = $self->{user};
my $host = $self->{host};
croak(ILLEGAL_STATE . " field 'host' is not set.") unless $host;
croak(ILLEGAL_STATE . " field 'user' is not set.") unless $user;
my $log_file = $self->{log_file};
my $log_stdout = $self->{log_stdout};
my $exp_internal = $self->{exp_internal};
my $exp_debug = $self->{exp_debug};
my $no_terminal = $self->{no_terminal};
my $raw_pty = $self->{raw_pty};
my $escape_char = $self->{escape_char};
my $ssh_option = $self->{ssh_option};
my $port = $self->{port};
my $rtup = $self->{restart_timeout_upon_receive};
# Gather flags.
my $flags = "";
$flags .= $escape_char ? "-e '$escape_char' " : "-e none ";
$flags .= "-p $port " if $port;
$flags .= "-T " if $no_terminal;
$flags .= $ssh_option if $ssh_option;
# this sets the ssh command line
my $ssh_string = $self->{binary} . " $flags $user\@$host";
# creating the Expect object
my $exp = new Expect();
# saving this instance
$self->{expect} = $exp;
# configuring the expect object
$exp->log_stdout($log_stdout);
$exp->log_file($log_file, "w") if $log_file;
$exp->exp_internal($exp_internal);
$exp->debug($exp_debug);
$exp->raw_pty($raw_pty);
$exp->restart_timeout_upon_receive($rtup);
my $success = $exp->spawn($ssh_string);
return (defined $success);
}
# string login ([$login_prompt, $password_prompt] [,$test_success]) - authenticates on the ssh server.
# This method responds to the authentication prompt sent by the SSH server.
# You can customize the "Login:" and "Password:" prompts that must be expected by passing their
# patterns as arguments to this method, although this method has default values that work to most
# SSH servers out there.
# It runs the run_ssh() method only if it wasn't run before(), but it'll die
# if run_ssh() returns false.
#
# param:
# $login_prompt: A pattern string used to match the "Login:" prompt. The default
# pattern is qr/ogin:\s*$/
#
# $password_prompt: A pattern string used to match the "Password:" prompt. The default
# pattern is qr/[Pp]assword.*?:|[Pp]assphrase.*?:/
#
# $test_success: 0 | 1. if 1, login will do an extra-test to verify if the password
# entered was accepted. The test consists in verifying if, after sending the password,
# the "Password" prompt shows up again what would indicate that the password was rejected.
# This test is disabled by default.
#
# OBS: the number of paramaters passed to this method will tell it what parameters are being passed:
# 0 parameters: login() : All the default values will be used.
# 1 parameter: login(1) : The $test_success parameter is set.
# 2 parameters: login("Login:", "Password:") : the $login_prompt and $password_prompt parameters are set.
# 3 parameters: login("Login:", "Password;", 1) : the three parameters received values in this order.
#
# returns:
# string: whatever the SSH server wrote in my input stream after loging in. This usually is some
# welcome message and/or the remote prompt. You could use this string to do your verification
# that the login was successful. The content returned is removed from the input stream.
# dies:
# IllegalState: if any of 'host' or 'user' or 'password' fields are unset.
# SSHProccessError: if run_ssh() failed to spawn the ssh process
# SSHConnectionError: if the connection failed for some reason, like invalid 'host' address or network problems.
sub login {
my Net::SSH::Expect $self = shift;
# setting the default values for the parameters
my ($login_prompt, $password_prompt, $test_success) = ( qr/ogin:\s*$/, qr/[Pp]assword.*?:|[Pp]assphrase.*?:/, 0);
# attributing the user defined values
if (@_ == 2 || @_ == 3) {
$login_prompt = shift;
$password_prompt = shift;
}
if (@_ == 1) {
$test_success = shift;
}
my $user = $self->{user};
my $password = $self->{password};
my $timeout = $self->{timeout};
my $t = $self->{terminator};
croak(ILLEGAL_STATE . " field 'user' is not set.") unless $user;
croak(ILLEGAL_STATE . " field 'password' is not set.") unless $password;
# spawns the ssh process if this wasn't done yet
if (! defined($self->{expect})) {
$self->run_ssh() or croak SSH_PROCESS_ERROR . " Couldn't start ssh: $!\n";
}
my $exp = $self->get_expect();
# loggin in
$self->_sec_expect($timeout,
[ qr/\(yes\/no\)\?\s*$/ => sub { $exp->send("yes$t"); exp_continue; } ],
[ $password_prompt => sub { $exp->send("$password$t"); } ],
[ $login_prompt => sub { $exp->send("$user$t"); exp_continue; } ],
[ qr/REMOTE HOST IDEN/ => sub { print "FIX: .ssh/known_hosts\n"; exp_continue; } ],
[ timeout => sub
{
croak SSH_AUTHENTICATION_ERROR . " Login timed out. " .
"The input stream currently has the contents bellow: " .
$self->peek();
}
]
);
# verifying if we failed to logon
if ($test_success) {
$self->_sec_expect($timeout,
[ $password_prompt =>
sub {
my $error = $self->peek();
croak(SSH_AUTHENTICATION_ERROR . " Error: Bad password [$error]");
}
]
);
}
# swallows any output the server wrote to my input stream after loging in
return $self->read_all();
}
# boolean waitfor ($string [, $timeout, $match_type])
# This method reads until a pattern or string is found in the input stream.
# All the characters before and including the match are removed from the input stream.
#
# After waitfor returns, use the methods before(), match() and after() to get the data
# 'before the match', 'what matched', and 'after the match' respectively.
#
# If waitfor returns false, whatever content is on input stream can be accessed with
# before(). In this case before() will return the same content as peek().
#
# params:
# $string: a string to be matched. It can be a regular expression or a literal string
# anb its interpretation as one or other depends on $match_type. Default is
# 're', what treats $string as a regular expression.
#
# $timeout: the timeout in seconds while waiting for $string
#
# $match_type: match_type affects how $string will be matched:
# '-re': means $string is a regular expression.
# '-ex': means $string is an "exact match", i.e., will be matched literally.
#
# returns:
# boolean: 1 is returned if string was found, 0 otherwise. When the match fails
# waitfor() will only return after waiting $timeout seconds.
#
# dies:
# SSH_CONNECTION_ABORTED if EOF is found (error type 2)
# SSH_PROCESS_ERROR if the ssh process has died (error type 3)
# SSH_CONNECTION_ERROR if unknown error (type 4) is found
sub waitfor {
my Net::SSH::Expect $self = shift;
my $pattern = shift;
my $timeout = @_ ? shift : $self->{timeout};
my $match_type = @_ ? shift : '-re';
croak ( ILLEGAL_ARGUMENT . "match_type '$match_type' is invalid." )
unless ($match_type eq '-re' || $match_type eq '-ex');
my ($pos, $error);
($pos, $error, $self->{match}, $self->{before}, $self->{after})
= $self->_sec_expect($timeout, $match_type, $pattern);
my $debug = $self->{debug};
# sanity verification
# Enforcing that match before and after have correct values
if (! defined $pos) { # if the pattern failed to match
# match should be undef
if (defined $self->{match}) {
if ($debug) {
carp ("The last expect() didn't match but \$exp->match() returned content '". $self->{match} ."'." .
" We'll set \$self->{match} to undef explicitly;");
}
$self->{match} = undef;
}
# after should be undef
if (defined $self->{after}) {
if ($debug) {
carp ("The last expect() didn't match but \$exp->after() returned content '". $self->{after} ."'." .
" We'll set \$self->{after} to undef explicitly;");
}
$self->{after} = undef;
}
}
return (defined $pos);
}
# string before() - returns the "before match" data of the last waitfor() call, or empty string.
# if the last waitfor() didn't match, before() will return all the current content on the input
# stream, just as if you had called peek() with the same timeout.
sub before {
my Net::SSH::Expect $self = shift;
return $self->{before};
}
# string match() - returns the "match" data of the last waitfor() call, or undef if didn't match.
sub match {
my Net::SSH::Expect $self = shift;
return $self->{match};
}
# string after() - returns the "after match" data of the last waitfor() call, or undef if didn't match.
sub after {
my Net::SSH::Expect $self = shift;
return $self->{after};
}
# send ("string") - breaks on through to the other side.
sub send {
my Net::SSH::Expect $self = shift;
my $send = shift;
croak (ILLEGAL_ARGUMENT . " missing argument 'string'.") unless ($send);
my $exp = $self->get_expect();
my $t = $self->{terminator};
$exp->send($send . $t);
}
# peek([$timeout]) - returns what is in the input stream without removing anything
# params:
# $timeout: how many seconds peek() will wait for input
# dies:
# SSH_CONNECTION_ABORTED if EOF is found (error type 2)
# SSH_PROCESS_ERROR if the ssh process has died (error type 3)
# SSH_CONNECTION_ERROR if unknown error (type 4) is found
sub peek {
my Net::SSH::Expect $self = shift;
my $timeout = @_ ? shift : $self->{timeout};
my $exp = $self->get_expect();
$self->_sec_expect($timeout);
return $exp->before();
}
# string eat($string)- removes all the head of the input stream until $string inclusive.
# eat() will only be able to remove the $string if it's currently present on the
# input stream because eat() will wait 0 seconds before removing it.
#
# Use it associated with peek to eat everything that appears on the input stream:
#
# while ($chunk = $exp->eat($exp->peak())) {
# print $chunk;
# }
#
# Or use the read_all() method that does the above loop for you returning the accumulated
# result.
#
# param:
# string: a string currently available on the input stream.
# If $string doesn't start in the head, all the content before $string will also
# be removed.
#
# If $string is undef or empty string it will be returned immediately as it.
#
# returns:
# string: the removed content or empty string if there is nothing in the input stream.
#
# dies:
# SSH_CONNECTION_ABORTED if EOF is found (error type 2)
# SSH_PROCESS_ERROR if the ssh process has died (error type 3)
# SSH_CONNECTION_ERROR if unknown error (type 4) is found
#
# debbuging features:
# The following warnings are printed to STDERR if $exp->debug() == 1:
# eat() prints a warning is $string wasn't found in the head of the input stream.
# eat() prints a warning is $string was empty or undefined.
#
sub eat {
my Net::SSH::Expect $self = shift;
my $string = shift;
unless (defined $string && $string ne "") {
if ($self->{debug}) {
carp ("eat(): param \$string is undef or empty string\n");
}
return $string;
}
my $exp = $self->get_expect();
# the top of the input stream that will be removed from there and
# returned to the user
my $top;
# eat $string from (hopefully) the head of the input stream
$self->_sec_expect(0, '-ex', $string);
$top .= $exp->match();
# if before() returns any content, the $string passed is not in the beginning of the
# input stream.
if (defined $exp->before() && !($exp->before() eq "") ) {
if ($self->{debug}) {
carp ("eat(): param \$string '$string' was found on the input stream ".
"after '". $exp->before() . "'.");
}
$top = $exp->before() . $top;
}
return $top;
}
# string read_all([$timeout]) - reads and remove all the output from the input stream.
# The reading/removing process will be interrupted after $timeout seconds of inactivity
# on the input stream.
sub read_all {
my Net::SSH::Expect $self = shift;
my $timeout = @_ ? shift : $self->{timeout};
my $out;
while ($self->_sec_expect($timeout, '-re', qr/[\s\S]+/)) {
$out .= $self->get_expect()->match();
}
return $out;
}
# boolean has_line([$timeout]) - tells if there is one more line on the input stream
sub has_line {
my Net::SSH::Expect $self = shift;
my $timeout = @_ ? shift : $self->{timeout};
$self->{next_line} = $self->read_line($timeout);
return (defined $self->{next_line});
}
# string read_line([$timeout]) - reads the next line from the input stream
# Read a line of text. A line is considered to be terminated by the 'teminator'
# character. Default is "\n". Lines can also be ended with "\r" or "\r\n".
# Remember to adequate this for your system with the terminator() method.
# When there are no more lines available, read_line() returns undef. Note that this doen't mean
# there is no data left on input stream since there can be a string not terminated with the
# 'terminator' character, notably the remote prompt could be left there when read_line() returns
# undef.
#
# params:
# $timeout: the timeout waiting for a line. Defaults to timeout().
#
# returns:
# string: a line on the input stream, without the trailing 'terminator' character.
# An empty string indicates that the line read only contained the 'terminator'
# character (an empty line)
# undef: when there are no more lines on the input stream.
#
sub read_line {
my Net::SSH::Expect $self = shift;
my $timeout = @_ ? shift : $self->{timeout};
my $t = $self->{terminator};
my $line = undef;
if ( $self->waitfor($t, $timeout) ) {
$line = $self->before();
}
return $line;
}
# string exec($cmd [,$timeout]) - executes a command, returns the complete output
sub exec {
my Net::SSH::Expect $self = shift;
my $cmd = shift;
my $timeout = @_ ? shift : $self->{timeout};
$self->send($cmd);
return $self->read_all($timeout);
}
sub close {
my Net::SSH::Expect $self = shift;
my $exp = $self->get_expect();
$exp->hard_close();
return 1;
}
# returns
# reference: the internal Expect object used to manage the ssh connection.
sub get_expect {
my Net::SSH::Expect $self = shift;
my $exp = defined ($self->{expect}) ? $self->{expect} :
croak (ILLEGAL_STATE_NO_SSH_CONNECTION);
return $exp;
}
# void restart_timeout_upon_receive( 0 | 1 ) - changes the timeout counter behaviour
# params:
# boolean: if true, sets the timeout to "inactivity timeout", if false
# sets it to "absolute timeout".
# dies:
# IllegalParamenter if argument is not given.
sub restart_timeout_upon_receive {
my Net::SSH::Expect $self = shift;
my $value = @_ ? shift : croak (ILLEGAL_ARGUMENT . " missing argument.");
$self->get_expect()->restart_timeout_upon_receive($value);
}
#
# Setter methods
#
sub host {
my Net::SSH::Expect $self = shift;
croak(ILLEGAL_ARGUMENT . " No host supplied to 'host()' method") unless @_;
$self->{host} = shift;
}
sub user {
my Net::SSH::Expect $self = shift;
croak(ILLEGAL_ARGUMENT . " No user supplied to 'user()' method") unless @_;
$self->{user} =shift;
}
sub password{
my Net::SSH::Expect $self = shift;
croak(ILLEGAL_ARGUMENT . " No password supplied to 'password()' method") unless @_;
$self->{password} = shift;
}
sub port {
my Net::SSH::Expect $self = shift;
croak(ILLEGAL_ARGUMENT . " No value passed to 'port()' method") unless @_;
my $port = shift;
croak (ILLEGAL_ARGUMENT . " Passed number '$port' is not a valid port number")
if ($port !~ /\A\d+\z/ || $port < 1 || $port > 65535);
$self->{port} = $port;
}
sub terminator {
my Net::SSH::Expect $self = shift;
$self->{terminator} = shift if (@_);
return $self->{terminator};
}
# boolean debug([0|1]) - gets/sets the $exp->{debug} attribute.
sub debug {
my Net::SSH::Expect $self = shift;
if (@_) {
$self->{debug} = shift;
}
return $self->{debug};
}
# number timeout([$number]) - get/set the default timeout used for every method
# that reads data from the input stream.
# The only exception is eat() that has its timeout defined as 0.
sub timeout {
my Net::SSH::Expect $self = shift;
if (! @_ ) {
return $self->{timeout};
}
my $timeout = shift;
if ( $timeout !~ /\A\d+\z/ || $timeout < 0) {
croak (ILLEGAL_ARGUMENT . " timeout '$timeout' is not a positive number.");
}
$self->{timeout} = $timeout;
}
#
# Private Methods
#
# _sec_expect(@params) - secure expect. runs expect with @params and croaks if problems happen
# Note: timeout is not considered a problem.
# params:
# the same parameters as expect() accepts.
# returns:
# the same as expect() returns
# dies:
# SSH_CONNECTION_ABORTED if EOF is found (error type 2)
# SSH_PROCESS_ERROR if the ssh process has died (error type 3)
# SSH_CONNECTION_ERROR if unknown error is found (error type 4)
sub _sec_expect {
my Net::SSH::Expect $self = shift;
my @params = @_ ? @_ : die ("\@params cannot be undefined.");
my $exp = $self->get_expect();
my ($pos, $error, $match, $before, $after) = $exp->expect(@params);
if (defined $error) {
my $error_first_digit = substr($error, 0, 1);
if ($error_first_digit eq '2') {
# found eof
croak (SSH_CONNECTION_ABORTED);
} elsif ($error_first_digit eq '3') {
# ssh process died
croak (SSH_PROCESS_ERROR . " The ssh process was terminated.");
} elsif ($error_first_digit eq '4') {
# unknown reading error
croak (SSH_CONNECTION_ERROR . " Reading error type 4 found: $error");
}
}
if (wantarray()) {
return ($pos, $error, $match, $before, $after);
} else {
return $pos;
}
}
1;