newline

Table of Contents

  1. The problem
  2. Digression: autoexpect
  3. Writing a script
    1. Command-line arguments
    2. Reading a password
    3. Connecting to the server
    4. Dealing with the prompts
    5. Elevating privileges
  4. Conclusion

An introduction to the expect tool

Programming, Shell

November 08, 2024

Expect is a command-line tool that can help you automate interactions with other programs. One of the main uses, and what I use it for, is to spawn a program, wait for one or more prompts, and react to them. It may initially be a bit hard to understand, also because expect scripts are written in Tcl, which has some unusual features by today’s standards. In this post, I’ll write a script to automate logging into a remote server, while answering any prompts (e.g. “change your password”, “accept this fingerprint”, etc.) automatically. This will hopefully serve as a useful overview of writing expect scripts.

The problem

First off, a disclaimer: I’m doing this with a server and network I trust. In many cases, it may not be a good idea to automate some of these things, such as accepting a fingerprint.

When I connect to a host vps via SSH, I might be asked a series of questions (but possibly not all of them). I want to answer all of them automatically, log into the server, and elevate my privileges to root level with sudo.

An interactive session with all prompts might look something like this (some lines aren’t included in the excerpt for brevity):

$ ssh vps
...
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
...
WARNING: Your password has expired.
You must change your password now and login again!
New password: password
Retype new password: password
passwd: password updated successfully
Connection to 10.89.97.83 closed.

$ ssh vps
me@vps$ sudo su
[sudo] password for me: password
root@vps:/home/me#

In the excerpt, I typed ‘yes’ and ‘password’ myself, and I’d like expect to do it for me, so that I only have to run the script, enter my password, and I’m automatically logged in as root.

Digression: autoexpect

A program called autoexpect may be included with your installation of expect. It lets you run a program (e.g. autoexpect ssh vps), it watches your interaction, and then generates an expect script based on that to automate your interaction. autoexpect can be a good way to create an initial script, which you can then adjust.

Writing a script

Let’s create a file sshw, meaning ‘ssh wrapper’, and put this at the top (you might need to replace /usr/bin/expect with the path to your expect executable):

#!/usr/bin/expect -f

In the rest of this section, I’ll be appending lines to this file, unless I explicitly say otherwise. We’d like to call the script from the command line with sshw vps, so the first step is extracting the hostname.

Command-line arguments

Command-line arguments get passed to the script in variables: argv0 for the name of the file containing the program, and the argv list for other arguments, with argc containing the number of arguments. Here’s how we’d save the first argument to the variable hostname (an explanation follows):

if { $argc < 1 } {
  send_user "No hostname provided\n"
  exit 1
}
set hostname [lindex $argv 0]

# And this is the command we will run:
set command "ssh $hostname"

if works like in any other imperative language. The condition is surrounded by { }, which is not a block, because there are no blocks in Tcl. All values in Tcl are strings, and braces are a quoting mechanism. A Tcl script contains lines (delimited by newline or semicolon) made up of word (delimited by a space), and quotes group words together to be treated as a single word. The first word on a line is the routine to run, and other words on the line are arguments. The if part could be rewritten like this, with the same effect: if "$argc < 1" "send_user \"No hostname provided\n\"; exit 1". We’re running the if routine, and passing it arguments. This is a key concept, so I’ll reiterate: everything is a string in Tcl.

$argc < 1 checks if there no arguments to the script – the dollar sign is variable interpolation. If the condition succeeds (there are no arguments), we print a message with send_user (it doesn’t automatically add a newline, so we add it in the string), and exit the script with an error status of 1.

If the condition fails, we set the variable hostname to the first argument. lindex list index gets the value at index (0-based) in list, so lindex $argv 0 gets the first argument. The brackets [ ] embed a script to be evaluated, the result of which is then embedded at the location of the brackets, like backticks or $() in shell (so in our case, [lindex $argv 0] is replaced with the first argument). set variable value is how you set a variable to value in Tcl (as a side note, set also returns the value). And # is how you start a comment.

Reading a password

Next, we want to read a password from the user, but we don’t want to show it in the terminal. Append this to the file:

stty -echo
send_user "password: "
expect_user -re "(.*)\n"
stty echo
set pass "$expect_out(1,string)\r"
send_user "\n"

stty changes terminal modes, like the stty command. stty -echo disables echoing (printing) of text you type to the terminal, and stty echo re-enables it; see the stty manpage for other options. We saw send_user already – it’s a print command.

expect_user reads user input, and the -re flag enables regular expressions The line expect_user -re "(.*)\n" can be read as: expect the user to type text matching the regular expression “(.*)\n” (any number of characters followed by a newline). Text captured in an expect_user statement gets automatically stored in the expect_out variable. expect_out(buffer) contains the whole buffer, and expect_out(N,string) contain the text matching a regular expression capture group N. In our case, expect_out(1,string) contains the text matched by (.*) in the expect_user statement; i.e., the password typed by the user. So, we save it in the variable pass (with a carriage return appended), and because we captured the user’s enter keypress, we print out a newline to move the cursor down.

Connecting to the server

Cool, we have a server and a password, let’s log in to the server (detailed breakdown of this follows):

proc login {pass command} {
  global spawn_id
  eval spawn $command
  expect {
    # Change password as needed
    -ex "New password: " {
      send -- "$pass"
      expect -exact "Retype new password: "
      send -- "$pass"
      expect eof
      login $pass $command
    }
    # Accept fingerprint as needed
    -ex "Are you sure you want to continue connecting (yes/no/\[fingerprint\])? " {
      send -- "yes\r"
      exp_continue
    }
    # On a shell prompt, stop expecting
    " $" {}
    # Catch all - we got something we didn't expect
    default {
      send_user "Got some unexpected output\n"
      exit 1
    }
  }
}

login $pass $command

With proc, we define a procedure login that takes two arguments: pass and command. Don’t let it fool you, these are all just strings, it could be rewritten as proc login "pass command" "...". At the bottom, we call the login procedure with the password and the command to run. Let’s break the rest of it down part-by-part.

Connecting to the server

These are the first two lines:

  global spawn_id
  eval spawn $command

I’ll explain the second one first. Remember that we set the variable command to the string "ssh $hostname". To evaluate a value as a script, we have to use eval.

The spawn routine runs a process, setting spawn_id to a descriptor referring to that process (making it the current process). This is what runs our ssh command.

spawn_id is what determines the “current process”, and it can be modified to control which process expect is speaking to. Since we’re inside a procedure definition, things we set will not be accessible outside of the procedure. However, since we want to spawn an SSH connection and be able to control it, even outside of the procedure, we have to make spawn_id global, with the keyword global.

Dealing with the prompts

As soon as SSH connects, we might get some prompts to answer, or just a shell prompt. This is where we have to expect and deal with different types of messages (explanation follows):

  expect {
    # Change password as needed
    -ex "New password: " {
      send -- "$pass"
      expect -exact "Retype new password: "
      send -- "$pass"
      expect eof
      login $pass $command
    }
    # Accept fingerprint as needed
    -ex "Are you sure you want to continue connecting (yes/no/\[fingerprint\])? " {
      send -- "yes\r"
      exp_continue
    }
    # On a shell prompt, stop expecting
    " $" {}
    # Catch all - we got something we didn't expect
    default {
      send_user "Got some unexpected output\n"
      exit 1
    }
  }

This is a big block, so let’s break it down.

How the expect routine works

expect has the form expect opts1 pattern1 body1 opts2 pattern2 body2.... It waits until the output of a spawned process matches one of the patterns, then executes its corresponding body, and finishes (returning the result of the body and moving to the next line after the closing brace of expect).

Patterns are unanchored by default, so they can match in the middle of a string. Without an option, they’re compared with Tcl’s string match, where you can use * to match any sequence of characters and ? to match a single character.

There are a few options you can pass to an expect pattern, I use -ex and -re the most. -ex matches an exact string, so literally, without interpreting any special characters (but still unanchored). -re matches a regular expression defined by Tcl’s regexp.

Dealing with the password update prompt

Here’s the first part:

    # Change password as needed
    -ex "New password: " {
      send -- "$pass"
      expect -exact "Retype new password: "
      send -- "$pass"
      expect eof
      login $pass $command
    }

When the SSH process asks for a new password, e.g. after a password expiry, this part gets executed. The send command sends characters to the spawned command, and we use a double dash to make sure that if there’s a leading dash in $pass, it doesn’t get interpreted as an option. send sends characters to the spawned process just like send_user sends characters to the screen of the user, and expect reads characters from the process just like expect_user reads characters from the user. So with send -- "$pass", we type the password into the SSH session.

The next expect statement has no body, so it just waits until the SSH process asks for the password again. We then send the password again. Because SSH closes the connection after the password has been updated, we expect eof (end-of-file). Finally, we call login again (recursively), with the same password and command.

Accepting the fingerprint

Here’s the second part:

    # Accept fingerprint as needed
    -ex "Are you sure you want to continue connecting (yes/no/\[fingerprint\])? " {
        send -- "yes\r"
        exp_continue
    }

This one’s simpler, and mostly what we’ve already seen before. If we get asked to accept the fingerprint, we send “yes” and a carriage return to the SSH process. Finally, we use exp_continue to re-execute the same big surrounding expect routine (because otherwise, the script would continue at the line after the closing brace of expect). Remember, brackets ([ ]) are like backticks, they interpolate the result of a script, so we have to escape them to match them literally.

Stop on a shell prompt

On a shell prompt, we’re done with the login process, so we can leave the expect statement:

    # On a shell prompt, stop expecting
    " $" {}

This is a simple pattern matching the start of my shell prompt (might be different for whoever reads this), and an empty body. It just says that, if the output of the spawned command matches the shell prompt, it should continue the script after the expect routine. Since it’s a simple pattern, I don’t use any options. We could also put this as the last pattern in the expect routine, and leave out the braces:

expect {
    # ...other patterns...
    " $"
}

Complain on unexpected output

The final pattern is the default:

    # Catch all - we got something we didn't expect
    default {
        send_user "Got some unexpected output\n"
        exit 1
    }

The body of default runs after a timeout, or at end-of-file. We just send an error message to the user, and exit the script with an error status of 1.

Elevating privileges

OK, at this point we’re logged in, but we still want to become root by way of sudo. We do that with:

send -- "sudo su\n"
expect -re "password for \[^ \]+: "
send -- "$pass"

At this point, there’s nothing new here: send the command sudo su to the process, expect it to ask for a password, and send the password.

Finally, we want to give control of the session to the user:

interact

The interact routine can let you do quite a few things (see man 1 expect), but here we only use it to get interactive control of the SSH session. If we didn’t include that, expect would stop after a timeout and the SSH connection would be closed (since it was spawned by expect).

Conclusion

And there it is – we’ve automated the login process and learned a bit of expect (and Tcl). I’m including the script in full below for those who want to copy-paste.

Here are some other useful resources:

Full SSH script: sshw
#!/usr/bin/expect -f
if { $argc < 1 } {
    send_user "No hostname provided\n"
    exit 1
}
set hostname [lindex $argv 0]

set command "ssh $hostname"

stty -echo
send_user "password: "
expect_user -re "(.*)\n"
stty echo
set pass "$expect_out(1,string)\r"
send_user "\n"


proc login {pass command} {
    global spawn_id
    eval spawn $command
    expect {
        # Change password as needed
        -ex "New password: " {
            send -- "$pass"
            expect -exact "Retype new password: "
            send -- "$pass"
            expect eof
            login $pass $command
        }
        # Accept fingerprint as needed
        -ex "Are you sure you want to continue connecting (yes/no/\[fingerprint\])? " {
            send -- "yes\r"
            exp_continue
        }
        # On a shell prompt, stop expecting
        " $" {}
        # Catch all - we got something we didn't expect
        default {
            send_user "Got some unexpected output\n"
            exit 1
        }
    }
}

login $pass $command

send -- "sudo su\n"
expect -re "password for \[^ \]+: "
send -- "$pass"

interact