The Advent of CLI - Day 5

Another Practical Example

Let put to good use the few things we have learned :slight_smile:

For some reasons I would like to be able to test if an IP address is valid.

So we gonna go step by step how to build a couple of functions, and then turn that
into our little command-line utility.

is_ipv4


Testing an IPv4 address

An IPv4 address consist of exactly 4 numeric components separated by dots .
in the range of 0 to 255.

So for the string “127.0.0.1” we would first split the string with the dot char,
and then evaluate each components to test their numerical value.

To split stuff in bash we can use $IFS which is an internal bash variable meaning “Internal Field Separator”, you can read some examples here Internal Variables - $IFS

Also you can read this tutorial Bash Split String - 3 Different Ways

To summarise

  • you assign a delimiter to $IFS
  • you read a string into an array
    or you assign a string to an array
  • and voila

small example

#!/bin/bash

FOOBAR="hello the big world and everything else"

SAVE_IFS=$IFS
IFS=" "
RESULT=($FOOBAR);
IFS=$SAVE_IFS

#echo ${RESULT[@]};

for (( i=0; i<${#RESULT[@]}; i+=1 )); do
    WORD=${RESULT[i]};
    echo $WORD
done

output:

hello
the
big
world
and
everything
else

And reusing that we can even create a small utility function to split strings

#!/bin/bash

function split_with() {
    local string="$1";
    local sep="$2";

    SAVE_IFS=$IFS
    IFS=$sep
    result=($string);
    IFS=$SAVE_IFS

    echo ${result[@]};
}

FOOBAR="hello the big world and everything else"
RESULT=($(split_with "$FOOBAR" " "))

for (( i=0; i<${#RESULT[@]}; i+=1 )); do
    WORD=${RESULT[i]};
    echo $WORD
done

and here we reuse the same function to split an IP address

#!/bin/bash

function split_with() {
    local string="$1";
    local sep="$2";

    SAVE_IFS=$IFS
    IFS=$sep
    result=($string);
    IFS=$SAVE_IFS

    echo ${result[@]};
}

FOOBAR="127.0.0.1"
RESULT=($(split_with "$FOOBAR" "."))

for (( i=0; i<${#RESULT[@]}; i+=1 )); do
    WORD=${RESULT[i]};
    echo $WORD
done

Few things to note

  • $IFS being a global internal variable
    we want to save it into $SAVE_IFS and restore it after use
  • $IFS can contains many chars
    for ex if you had this string FOOBAR="hello the-big_world and-everything else"
    you could split on either (space) or - or _
    with RESULT=($(split_with "$FOOBAR" " -_"))
    or simply put by assigning those 3 chars to IFS, eg. IFS=" -_"
  • if you need to split a string on more than 1 char, see the tutorial above
    Bash Split String - 3 Different Ways
    where it uses "${s%%"$delimiter"*}"
  • we use what we call a bashism to return an array from a function
    eg. echo ${result[@]};, this will not work in other shell
    with ${result[@]} returning each items of the array
    (see details bellow)
  • because we return each items of the array as a quoted expansion
    we need to save the result into an array
    eg. RESULT=($(split_with "$FOOBAR" " -_"))
    NOT RESULT=$(split_with "$FOOBAR" " -_")
  • to iterate through the array we use ${#RESULT[@]}
    which is the “length of the array”

Let’s review quickly arrays special variables

| notation   | description                     |
|------------|---------------------------------|
| ${arr[*]}  | All of the items in the array   |
| ${arr[@]}  | All of the items in the array   |
| ${!arr[*]} | All of the indexes in the array |
| ${#arr[*]} | Number of items in the array    |
| ${#arr[@]} | Number of items in the array    |
| ${#arr[0]} | Length of item zero             |

Note that the @ sign can be used instead of the * in constructs such as ${arr[*]},
the result is the same except when expanding to the items of the array within a quoted string.
In this case the behavior is the same as when expanding $* and $@ within quoted strings:
${arr[*]} returns all the items as a single word, whereas ${arr[@]} returns each item as a separate word.
from Linux Journal Bash Arrays

If we analyse a little, with

FOOBAR="127.0.0.1"
RESULT=($(split_with "$FOOBAR" "."))

within the function split_with
at the end echo ${result[@]};
is returning all the items of the array as separate quoted words
eg. "127" "0" "0" "1"

and because we save the result between parenthesis ( and )
it then create an array with each of those items
eg. RESULT=("127" "0" "0" "1")

If we were returning ${result[*]}; all the items would be quoted
eg. "127 0 0 1"

and if we were not saving the results using parenthesis
eg. RESULT="127" "0" "0" "1" or RESULT="127 0 0 1"
we would create a string and not an array


Now that we know how to split a string, we need to test and evaluate each of
the items to be sure they fall into an IPv4 address.

In order to do that we gonna use regexp with bash strings :slight_smile:

here another little script

#!/bin/bash

function split_with() {
    local string="$1";
    local sep="$2";

    SAVE_IFS=$IFS
    IFS=$sep
    result=($string);
    IFS=$SAVE_IFS

    echo ${result[@]};
}

function is_ipv4() {
    local ipaddress="$1";
    local result=1;

    if [[ $ipaddress =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
        local ip=($(split_with "$ipaddress" "."))
        if (( (${ip[0]} <= 255) && (${ip[1]} <= 255) && (${ip[2]} <= 255) && (${ip[3]} <= 255) )); then
            result=0
        fi
    fi

    return $result;
}

iptest="127.0.0.1"

if is_ipv4 "$iptest"; then
    echo "$iptest is a valid IPv4";
else
    echo "$iptest is not a valid IPv4";
fi

when you run this script the result should be

127.0.0.1 is a valid IPv4

Let review the function is_ipv4.

It uses two local variables

local ipaddress="$1";
local result=1;

you use the function by passing one parametter, eg. is_ipv4 "127.0.0.1",
and then the function returns 0 for success or non-zero (1) for failure.

this part is scary, but don;t run away yet

[[ $ipaddress =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]

basically it take the string $ipaddress on the left hand side
and compare it with =~ to regular expression on the right hand side

the regexp ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$

  • ^ from the beginning of the string
  • [0-9] test for a digit between 0 to 9
  • [0-9]{1,3} test for digit length from 1 to 3
    eg. 0000 would fail
  • \. the dot character
  • $ till the end of the string

if you put it all together:
“from the beginning of the string test for exactly 4 digits composed of [0-9] of length 1 to 3 and separated by the . character till the end of the string”

It may look scary only if you don’t know or use regexp, not bash fault,
it would be the same regexp in ActionScript, JavaScript, etc.

so if the regexp matches then we declare a local array variable
local ip=($(split_with "$ipaddress" "."))

we already know the string has exactly 4 components and those are separated by the dot . character

then we test each components to know if they are “less than or equal” to 255,
eg. ${ip[0]} <= 255 test the first component

altogether it gives
if (( (${ip[0]} <= 255) && (${ip[1]} <= 255) && (${ip[2]} <= 255) && (${ip[3]} <= 255) ));

and if this is true we assign 0 to the result, eg. result=0

finally at the end of the function we returns the result
eg. return $result;

Returning either 0 or 1 allow to use this function results as booleans
and so later on we can test with if is_ipv4 "$iptest";

we could have also done

  • if (is_ipv4 "$iptest");
  • if $(is_ipv4 "$iptest");

Finally, let’s add a bit of colours to our output, and let’s run some tests.

#!/bin/bash

function split_with() {
    local string="$1";
    local sep="$2";

    SAVE_IFS=$IFS
    IFS=$sep
    result=($string);
    IFS=$SAVE_IFS

    echo ${result[@]};
}

function is_ipv4() {
    local ipaddress="$1";
    local result=1;

    if [[ $ipaddress =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
        local ip=($(split_with "$ipaddress" "."))
        if (( (${ip[0]} <= 255) && (${ip[1]} <= 255) && (${ip[2]} <= 255) && (${ip[3]} <= 255) )); then
            result=0
        fi
    fi

    return $result;
}

msg_red() {
  local msg="$1"
  echo -e "\033[91m$msg\033[0m";
}

msg_green() {
  local msg="$1"
  echo -e "\033[92m$msg\033[0m";
}

iptests=(
    4.2.2.2
    a.b.c.d
    192.168.1.1
    0.0.0.0
    255.255.255.255
    255.255.255.256
    192.168.0.1
    192.168.0
    1234.123.123.123
    127.0.0.1
    ...
    1.2.3.4.5
)

# run tests
for (( i=0; i<${#iptests[@]}; i+=1 )); do
    iptest=${iptests[i]};
    if is_ipv4 "$iptest"; then
        msg_green "$iptest is a valid IPv4";
    else
        msg_red "$iptest is not a valid IPv4";
    fi
done

when you run this script you should get

4.2.2.2 is a valid IPv4
a.b.c.d is not a valid IPv4
192.168.1.1 is a valid IPv4
0.0.0.0 is a valid IPv4
255.255.255.255 is a valid IPv4
255.255.255.256 is not a valid IPv4
192.168.0.1 is a valid IPv4
192.168.0 is not a valid IPv4
1234.123.123.123 is not a valid IPv4
127.0.0.1 is a valid IPv4
... is not a valid IPv4
1.2.3.4.5 is not a valid IPv4

in green for the valid IP addresses and red for the non-valid ones

This little example has been largely inspired by this Linux Journal article
Validating an IP Address in a Bash Script


Couple of things to review though

By default when you delare an array, it does use $IFS to separate the items of that array,
and the default characters used by $IFS are (space), \t (tab) and \n (newline).

That’s why we can declare our $iptests array like that

iptests=(
    4.2.2.2
    a.b.c.d
    192.168.1.1
    0.0.0.0
    255.255.255.255
    255.255.255.256
    192.168.0.1
    192.168.0
    1234.123.123.123
    127.0.0.1
    ...
    1.2.3.4.5
)

I didn’t make a special case of it but we can also see how to loop through an array with

for (( i=0; i<${#iptests[@]}; i+=1 )); do
    # get an array item
    iptest=${iptests[i]};
    # do something here
done
  • use (( and )) for an arithmetic expression
  • then use an i variable for your array index
  • use ${#iptests[@]} for the length of your array
  • and increment your i variable with i+=1
  • get the array item value with ${iptests[i]}

For displaying colors we simply use ANSI escape codes
see ShellHacks - Bash Colors
and Bash Prompt HOWTO: Chapter 6. ANSI Escape Sequences: Colours and Cursor Movement

Note::
the format echo -e "\e[31mRed Text\e[0m" does not work under macOS bash v3,
it is more portable to use \033 eg. echo -e "\033[31mRed Text\033[0m".

Finally, we can save this script as aa-is_ipv4

#!/bin/bash

function split_with() {
    local string="$1";
    local sep="$2";

    SAVE_IFS=$IFS
    IFS=$sep
    result=($string);
    IFS=$SAVE_IFS

    echo ${result[@]};
}

function is_ipv4() {
    local ipaddress="$1";
    local result=1;

    if [[ $ipaddress =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
        local ip=($(split_with "$ipaddress" "."))
        if (( (${ip[0]} <= 255) && (${ip[1]} <= 255) && (${ip[2]} <= 255) && (${ip[3]} <= 255) )); then
            result=0
        fi
    fi

    return $result;
}

IP_ADDRESS="$1"

if [[ -z "${IP_ADDRESS}" ]]; then
    echo "ip address is missing"
    echo "eg. $ ./${0##*/} 127.0.0.1"
    exit 1
fi

if is_ipv4 "$IP_ADDRESS"; then
    exit 0;
else
    exit 1;
fi

and reuse it later within other scripts, for example

#!/bin/bash

current_dir() {
    echo "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
}

# --- main ---

CURDIR=$(current_dir)
IP_ADDRESS="$1"

if $($CURDIR/aa-is_ipv4 "$IP_ADDRESS"); then
    echo "we found a valid IP address"
fi

# etc.

That’s all folks :slight_smile: