StabbyMc's Intro to Shell Scripting

From FedoraProject

(Difference between revisions)
Jump to: navigation, search
(Checking correct command invocation)
(Checking correct command invocation)
 
Line 191: Line 191:
 
So several new bits here.  The first is our syntax of what is provided to if.  There is a command called test where you can test a condition.  If the condition is true, test returns a 0, or success, and the contents after <B>then</B> will be executed.  If the condition is false, test will provide a non 0 and the contents after <B>then</B> will be skipped.  Fedora also includes a command [ which operates in the same way as test.  The expression <B>[ -n "$*" ]</B> is checking to see if the contents of $* are non-zero.  If $* has non-zero contents, ie it contains arguments passed via the command line, then we'll execute the commands after <B>then</B>.
 
So several new bits here.  The first is our syntax of what is provided to if.  There is a command called test where you can test a condition.  If the condition is true, test returns a 0, or success, and the contents after <B>then</B> will be executed.  If the condition is false, test will provide a non 0 and the contents after <B>then</B> will be skipped.  Fedora also includes a command [ which operates in the same way as test.  The expression <B>[ -n "$*" ]</B> is checking to see if the contents of $* are non-zero.  If $* has non-zero contents, ie it contains arguments passed via the command line, then we'll execute the commands after <B>then</B>.
  
<B>UPDATE:</B> siXy, from irc.freenode.net #rhel, points out that bash also includes the [[ operator which functions similarly to [ and test.  Since it's a built-in, a separate process does not need to be spawned to spawn the command included within it.  Ultimately, this change will speed up the script execution and cause less system overhead in terms of process management.  Also, siXy points out that <B>[[</B> and <B>]]</B> will handle unexpected spaces in it's contents better than our other methods.  I've updated all of the examples in this document to use <B>[[</B> and <B>]]</B>, thanks siXy!
+
<B>UPDATE:</B> siXy, from irc.freenode.net #rhel, points out that bash also includes the [[ operator which functions similarly to [ and test.  siXy points out that <B>[[</B> and <B>]]</B> will handle unexpected spaces in it's contents better than test or [.  I've updated all of the examples in this document to use <B>[[</B> and <B>]]</B>, thanks siXy!
  
 
The second new item is the use of <B>else</B>.  In the event that the condition tested in the if is false, the <B>then</B> branch is skipped, but if there is an <B>else</B> branch it's contents will be executed.  So in this example, we check to see if the script is invoked with command line arguments, if it is not, we provide a usage message and exit the script.  The significance of exit is that not only will it terminate the script from running any additional commands, but it will also set an exit value.  Remember that from earlier an exit value of 0 signifies a correct execution of our program, where an exit of a non-zero value indicates an error of some type occurred.  Now, if we were to get a complaint from someone about our program, if they tell us that the exit value is 7, we know it's because they're running the program without providing any command line arguments.
 
The second new item is the use of <B>else</B>.  In the event that the condition tested in the if is false, the <B>then</B> branch is skipped, but if there is an <B>else</B> branch it's contents will be executed.  So in this example, we check to see if the script is invoked with command line arguments, if it is not, we provide a usage message and exit the script.  The significance of exit is that not only will it terminate the script from running any additional commands, but it will also set an exit value.  Remember that from earlier an exit value of 0 signifies a correct execution of our program, where an exit of a non-zero value indicates an error of some type occurred.  Now, if we were to get a complaint from someone about our program, if they tell us that the exit value is 7, we know it's because they're running the program without providing any command line arguments.

Latest revision as of 14:54, 16 March 2009

The purpose of this page is to provide an easily readable document than the transcript of the Classroom session given on this subject. The example this document works with is about adding a new user, or users, to a machine, and some common customizations around this process.

Contents

[edit] A very simple shell script

The following has been added to a file, via vi, emacs, gedit, or the reader's preferred text editor. For the purposes of the example, assume the file used is useradd-wrapper.sh

useradd frank
echo mypassword | passwd --stdin frank

At it's simplest, a shell script is a series of commands to execute. You may not have seen passwd's --stdin option before, it allows the passwd command to take the target password to assign to a user over stdin or through an input redirection operator like a |. So in the example above, a user named frank is added to the system, then has an initial password set to mypassword.

[edit] Invoking a shell script

There are several ways that we can execute the above script. We could:

bash useradd-wrapper.sh

Using this method, we're invoking the bash shell and using it to execute the commands stored in the useradd-wrapper.sh script.

Another alternative, which is more popular, is to make the script executable and using the script name as the "command" to use on the shell commandline. So something like:

chmod a+x useradd-wrapper.sh

in order to make the file executable and

./useradd-wrapper.sh

to execute the instructions stored within the script.

The last alternative I'll bring up is running the script without the ./ at the beginning of the command. The problem is that usually the current directory (.) is not included on the PATH variable, which is the list of directories in which to search for executables. Fedora, however, includes a directory in each user's home directory on their default PATH built at login. ~user/bin, or in other words, in the user's home, the subdirectory bin/ is included by default. Our example uses useradd and other commands which are run as root, so for our example, we could move the useradd-wrapper.sh script to the /root/bin directory. If the file has already been made executable with the previously mentioned chmod command, we would now invoke the script as simply:

useradd-wrapper.sh

[edit] Some simple enhancements (#!, Comments, and Positional Parameters)

Lets make some quick enhancements to our script.

#!/bin/bash

useradd $1
echo mypassword | passwd --stdin $1

# Invocation ./myscript.sh frank

The first change is the addition of the #!/bin/bash line. The significance of the #! line is that it indicates what interpreter, or language, to use when executing the commands in this script. If you've worked with perl, python, or another shell script language, you've likely seen a line like this. The issue that this fixes is that a bash script will be invoked, even if the shell running the script from the command line is different. The #! should be the first line in the script. Additional lines that begin with # will be interpreted as comments. We see a comment line as the last line in this script.

The other major change to our simple script has been the addition of a variable to replace the username we want to work with. Variables for our use within the script are created upon execution. We'll see a few of these as the document progresses, but above we're using $1. $1 represents the first argument passed to the shell script from the command line, so as the comment as the bottom of the file suggests, our command line would now include the script to execute and the user we wish to add and set the password for. Other positional parameters of note. $0 stores the name of the script called, $2 the second argument passed, $3 the third argument, ...

[edit] Quoted Execution

Next we're going to execute a command from our script who's output we wish to store, rather than display.

#!/bin/bash

useradd $1

password=`mkpasswd`

echo $password | passwd --stdin $1

# Invocation ./myscript.sh frank

Quickly, a sidebar about mkpasswd. Very likely it's not installed on your machine. The expect package provides the mkpasswd command. The mkpasswd command generates randomized strings of upper and lower case characters, digits, and special characters. The length of the string, selections of how many of each type of character, can be customized with options to the mkpasswd command.

What we're doing above is invoking a sub-shell, within that shell run mkpasswd, then take the output of mkpasswd (the randomized string) and store it in a newly declared variable, password. Later we change the command where we call passwd to pass it the random string rather than the static string "mypassword". I know there's currently a problem with this. If the password is set to a random string, how do we see it? We will fix this later when we talk about notifying the user of their new account.

One other note about quoted execution, above we're using the ` character to open and close the executed command. Another way we could express this is $(mkpasswd) Both methods work equally well, I prefer ` since it is easier to type, though I've been told that without a US keyboard $() is easier to write into a script.

[edit] Adding Choices with if

Shell script has evolved over the years into a programming language, which means it has conditional statements. In this section we're going to see the first use of an if statement.

#!/bin/bash

useradd $1

if <SOME COMMAND IS SUCCESSFUL>
then
  password=`mkpasswd`
  echo $password | passwd --stdin $1
fi

# Invocation ./myscript.sh frank

One of the problems with our script so far is that if the user already exists, the useradd fails, yet we would still continue to overwrite their password with a string of random characters. An if statement will allow us to run a command and look at the exit value of that command. If the command runs successfully, the command sets an exit value of 0 and the if statement's check will be successful and the internal commands will be executed. If the command is unsuccessful, the command returns a non-zero value on exit and the if command will be interpreted as unsuccessful, which causes the if statement's internal commands to be skipped. The if's conditional commands are included between the then and fi commands. Lets fill in the the loop to completeness:

#!/bin/bash

if useradd $1
then
  password=`mkpasswd`
  echo $password | passwd --stdin $1
fi

# Invocation ./myscript.sh frank

So at this point, our script tries to add the user, and if successful, will then set their password to a randomized string of characters. A little further down we'll run a test to see if the user exists as our if condition rather than checking the exit code from an attempted useradd.

[edit] Writing the iterative for loop

The idea behind the for loop is that you want to iterate through the loop's commands once for each item in the list of space separated values. Each time the loop is to be executed, the current item in the list to be iterated will be assigned to be the value of the placeholder variable. The commands to be executed for each iteration are bound by the do and done commands.

#!/bin/bash

for <PLACEHOLDER VARIABLE> in <SPACE SEPARATED LIST OF VALUES>
do

  if useradd $1
  then
    password=`mkpasswd`
    echo $password | passwd --stdin $1
  fi

done

# Invocation ./myscript.sh frank

The reason we're looking at this looping structure is that currently our script accepts only one user as an argument of a person to add, and we're going to use the for loop to change things such that we can provide multiple users on the command line of the script.

Filling in the details for the loop contents, one method would be something like:

#!/bin/bash

for user in $1 $2 $3 $4 $5
do

  if useradd $user
  then
    password=`mkpasswd`
    echo $password | passwd --stdin $user
  fi

done

# Invocation ./myscript.sh frank joe sally ...

In the above script, we'd be limited to no more than 5 users as arguments. So in the next example we're going to change the list of positional parameters to a special shell variable, $*, which will contain all arguments passed to the script. If the arguments were bill frank joe, then the $* variable would contain the string "bill frank joe".

#!/bin/bash

for user in $*
do

  if useradd $user
  then
    password=`mkpasswd`
    echo $password | passwd --stdin $user
  fi

done

# Invocation ./myscript.sh frank joe sally ...

[edit] Adding more error checking

[edit] Checking correct command invocation

Thusfar, our script hasn't handled erroneous user input very well. What happens when they call the script, but neglect to provide any users to add? Below we add an if statement wrapping the whole script to check and see if arguments are passed. If arguments are not passed, the user will fall into an else branch and receive a usage error message.

#!/bin/bash

if [[ -n "$*" ]]
then

  for user in $*
  do

    if useradd $user
    then
      password=`mkpasswd`
      echo $password | passwd --stdin $user
    fi

  done

else
  echo "Usage:  $0 <new username> <new username> <new username> ..."
  exit 7
fi

So several new bits here. The first is our syntax of what is provided to if. There is a command called test where you can test a condition. If the condition is true, test returns a 0, or success, and the contents after then will be executed. If the condition is false, test will provide a non 0 and the contents after then will be skipped. Fedora also includes a command [ which operates in the same way as test. The expression [ -n "$*" ] is checking to see if the contents of $* are non-zero. If $* has non-zero contents, ie it contains arguments passed via the command line, then we'll execute the commands after then.

UPDATE: siXy, from irc.freenode.net #rhel, points out that bash also includes the [[ operator which functions similarly to [ and test. siXy points out that [[ and ]] will handle unexpected spaces in it's contents better than test or [. I've updated all of the examples in this document to use [[ and ]], thanks siXy!

The second new item is the use of else. In the event that the condition tested in the if is false, the then branch is skipped, but if there is an else branch it's contents will be executed. So in this example, we check to see if the script is invoked with command line arguments, if it is not, we provide a usage message and exit the script. The significance of exit is that not only will it terminate the script from running any additional commands, but it will also set an exit value. Remember that from earlier an exit value of 0 signifies a correct execution of our program, where an exit of a non-zero value indicates an error of some type occurred. Now, if we were to get a complaint from someone about our program, if they tell us that the exit value is 7, we know it's because they're running the program without providing any command line arguments.

[edit] Changing our check for adding the user

Earlier I promised a better check before we added the user account:

#!/bin/bash

if [[ -n "$*" ]]
then

  for user in $*
  do

    if ! getent passwd $user
    then

      useradd $user
      password=`mkpasswd`
      echo $password | passwd --stdin $user

    else
    echo "$user already exists"
    fi

  done

else
  echo "Usage:  $0 <new username> <new username> <new username> ..."
  exit 7
fi

Now the if statement inside the for loop checks the getent command. getent is used to check a database like passwd, group, shadow, etc. for a listing. The ! before the getent command is a logical not. So if we're not able to look up the current username we're working on in a passwd database, then go through the steps to add the user. If the user is able to be looked up, we fall into the else and print an error message. This approach is better than looking at the exit value for useradd for a couple of reasons, one is that we're not attempting to add the user and relying on the useradd to provide error messages and second, getent not only checks the local /etc/passwd file, but will also check other configured authentication mechanisms like NIS or LDAP.

[edit] Cleaning up script output

Currently we're still seeing the output or error of every command running in this script. Let's redirect the output and errors to /dev/null so that the only output or errors provided are the ones we've built into the script as echos.

#!/bin/bash

if [[ -n "$*" ]]
then

  for user in $*
  do

    if ! gentent passwd $user > /dev/null 2> /dev/null
    then

      useradd $user &> /dev/null
      password=`mkpasswd`
      echo $password | passwd --stdin $user &> /dev/null
      echo "Added user: $user"

    else
    echo "ERROR: unable to add user: $user"
    fi

  done

else
  echo "Usage:  $0 <new username> <new username> <new username> ..."
  exit 7
fi

We can use the standard > and 2> to redirect output and error messages, respectively, or bash provides the &> operator for redirecting both errors and output to the same location.

[edit] Other Improvement Ideas

[edit] Adding quotas

#!/bin/bash

if [[ -n "$*" ]]
then

  for user in $*
  do

    if ! gentent passwd $user &> /dev/null
    then

      useradd $user > /dev/null 2> /dev/null
      password=`mkpasswd`
      echo $password | passwd --stdin $user &> /dev/null

      # Now that we've got the user added, lets quota them too, but
      # we want to quota possibly problematic filesystems.  This requires
      # the quota subsystem has been intialized on the target filesystems.

      if mount | grep ' /home ' &> /dev/null
      then
        setquota $user 204800 256000 50000 750000 /home
      fi

      if mount | grep ' /tmp ' &> /dev/null
      then
        setquota $user 0 51200 0 1000 /tmp
      else
        setquota $user 0 51200 0 1000 /

        # this could also affect directories like /var/tmp or /var/spool/mail
        # if /var is not a separate filesystem, so think about what this
        # machine does and what filesystem user's files are charged to.
      fi

    else
    echo "Unable to add user $user"
    fi

  done

else
  echo "Usage:  $0 <new username> <new username> <new username> ..."
  exit 7
fi

Above we're using setquota to assign block and inode limits to our newly added user accounts. Quotas are implemented per filesystem, so we're using mount to figure out which directories are actually distinct filesystems, then assigning quotas if they are. We could probably use some more logic to make these choices more accurate.

[edit] Tokenizing values

#!/bin/bash

home_soft_blk="204800"
home_hard_blk="256000"
home_soft_ino="50000"
home_hard_ino="75000"

tmp_soft_blk="0"
tmp_hard_blk="51200"
tmp_soft_ino="0"
tmp_hard_ino="1000"

useradd_opts=""
#
# Please change the values above, rather than editing below
#

if [[ -n "$*" ]]
then

  for user in $*
  do

    if ! gentent passwd $user &> /dev/null
    then

      useradd $useradd_opts $user > /dev/null 2> /dev/null
      password=`mkpasswd`
      echo $password | passwd --stdin $user &> /dev/null

      # Now that we've got the user added, lets quota them too, but
      # we want to quota possibly problematic filesystems.  This requires
      # the quota subsystem has been intialized on the target filesystems.

      if mount | grep ' /home ' &> /dev/null
      then
        setquota $user $home_soft_blk $home_hard_blk $home_soft_ino $home_hard_ino /home
      fi

      if mount | grep ' /tmp ' &> /dev/null
      then
        setquota $user $tmp_soft_blk $tmp_hard_blk $tmp_soft_ino $tmp_hard_ino /tmp
      else
        setquota $user $tmp_soft_blk $tmp_hard_blk $tmp_soft_ino $tmp_hard_ino /tmp

        # this could also affect directories like /var/tmp or /var/spool/mail
        # if /var is not a separate filesystem, so think about what this
        # machine does and what filesystem user's files are charged to.
      fi

      echo "Added user: $user"

    else
    echo "ERROR: $user appears to already exist"
    fi

  done

else
  echo "Usage:  $0 <new username> <new username> <new username> ..."
  exit 7
fi

Its a nice idea to take values that you may want to change and create variables for them. Now, with these settings tokenized, we can change their values at the beginning of the script rather than going down into the bits that do work. This is also desirable because it is less likely for a mistake to be made that breaks the script if one is changing the values in the variables rather than in the more difficult surroundings of the command using these variables in the script itself.

[edit] Notifying the new user of their account

#!/bin/bash

home_soft_blk="204800"
home_hard_blk="256000"
home_soft_ino="50000"
home_hard_ino="75000"

tmp_soft_blk="0"
tmp_hard_blk="51200"
tmp_soft_ino="0"
tmp_hard_ino="1000"

useradd_opts=""

#
# Please change the values above, rather than editing below
#

if [[ -n "$*" ]]
then

  for user in $*
  do

    if ! gentent passwd $user &> /dev/null
    then

      useradd $useradd_opts $user > /dev/null 2> /dev/null
      password=`mkpasswd`
      echo $password | passwd --stdin $user &> /dev/null

      # Now that we've got the user added, lets quota them too, but
      # we want to quota possibly problematic filesystems.  This requires
      # the quota subsystem has been intialized on the target filesystems.

      if mount | grep ' /home ' &> /dev/null
      then
        setquota $user $home_soft_blk $home_hard_blk $home_soft_ino $home_hard_ino /home
      fi

      if mount | grep ' /tmp ' &> /dev/null
      then
        setquota $user $tmp_soft_blk $tmp_hard_blk $tmp_soft_ino $tmp_hard_ino /tmp
      else
        setquota $user $tmp_soft_blk $tmp_hard_blk $tmp_soft_ino $tmp_hard_ino /tmp

        # this could also affect directories like /var/tmp or /var/spool/mail
        # if /var is not a separate filesystem, so think about what this
        # machine does and what filesystem user's files are charged to.
      fi

      echo "Hello,
You have a new account on `hostname`.  In order to access this account you'll
log in with the following credentials.
Username - $user
Password - $password
You will likely want to change your password, this machine requires passwords
that are at least 6 characters long and contain a mixture of upper and lower
case as well as digits and/or special characters.  Your new password will be
rejected by the system if it is based on an easily guessable pattern.
Your account also has limitations placed on how much disk space files you own
can consume, to inspect this limit at anytime, please run the quota command.
If you have concerns or need additional assistance, please contact:
admin\@somedomain.com
-Scott" > /tmp/mail_body

      mail -s "Account details for `hostname`" $user\@somedomain.com < /tmp/mail_body
      rm -f /tmp/mail_body

      echo "Added user: $user"

    else
    echo "ERROR: $user appears to already exist"
    fi

  done

else
  echo "Usage:  $0 <new username> <new username> <new username> ..."
  exit 7
fi

Earlier we set the user's password to a random string using mkpasswd, but still needed a way of notifying them what it is. Above we create a file /tmp/mail_body, then use mail to send an e-mail to the user's e-mail address with this account information. The achilles heel would be if our machine was the mail server for the domain, then the user wouldn't have the ability to login to get their login information...

[edit] Other scripting ideas

Here is a place for additional ideas for using shell scripting.

[edit] Automatically applying updates

#!/bin/bash

if [[ -n "$*" ]]
then

  for machine in $*
  do
    ssh $machine "yum update -y &> /dev/null"
  done

else
  echo "Usage:  $0 <hostname or IP> <hostname or IP> <hostname or IP> ..."
  exit 7
fi

If you have ssh key-based authentication set up, you could run the following without providing any passwords.