Track 10 · Lesson 6 of 6

Build a subnet calculator

Build a shell script that turns an address and prefix into the network, broadcast, host range and host count, including the /31 and /32 cases most calculators get wrong.


This lesson builds the same calculator as the subnet tool, so you can do the whole job from the shell without opening it. By the end you will have a script called subnetcalc.sh that takes 192.168.1.42/24 and prints the network, the broadcast address, the mask, the wildcard, the first and last usable host, and how many addresses there are.

You build it up a few lines at a time, running it after every step, and the finished script sits in one block at the end so you can copy the whole thing at once.

What you'll be able to do

  • See which subnet your own phone is on.
  • Turn a dotted quad into a single number and back.
  • Build a subnet mask from a prefix length.
  • Get the network with a bitwise AND and the broadcast with an OR.
  • Handle a /31 and a /32 correctly, which most calculators do not.
  • Reject a mask that is not a run of ones followed by zeros.

Step 1: Look at your own subnet first

Before calculating anything, look at what you are actually on.

ip -o -4 addr show
1: lo    inet 127.0.0.1/8 scope global dynamic
2: wlan0 inet 192.168.1.24/24 scope global dynamic

192.168.1.24/24 is your address and the prefix. The prefix says how many of the 32 bits are fixed for the network. A /24 fixes 24 and leaves 8 for hosts, which is two to the eight, which is 256 addresses. Every number the finished script prints comes out of that one idea.

If ip is missing, pkg install iproute2 provides it.

Step 2: An address is really one number

Four numbers between zero and 255, each worth one byte. Multiply each by its place value and add them up and you have a single integer.

cat > step2.sh <<'EOF'
ip_to_int() {
  printf '%s' "$1" | awk -F. '{ printf "%.0f", ($1 * 16777216) + ($2 * 65536) + ($3 * 256) + $4 }'
}

for a in 192.168.1.42 10.0.0.5 255.255.255.0; do
  printf '%-16s -> %s\n' "$a" "$(ip_to_int "$a")"
done
EOF
bash step2.sh
192.168.1.42     -> 3232235818
10.0.0.5         -> 167772165
255.255.255.0    -> 4294967040

-F. splits the input on dots, so $1 to $4 are the four octets. The %.0f matters: awk does its arithmetic in floating point, and printing an integer that large without it gives you scientific notation. 256, 65536 and 16777216 are the place values of the four bytes.

Step 3: And back again

The reverse is a shift and a mask per byte.

cat > step3.sh <<'EOF'
int_to_ip() {
  local n=$1
  printf '%d.%d.%d.%d' $(( (n >> 24) & 255 )) $(( (n >> 16) & 255 )) $(( (n >> 8) & 255 )) $(( n & 255 ))
}

for n in 3232235818 4294967295 0; do
  printf '%-12s -> %s\n' "$n" "$(int_to_ip "$n")"
done
EOF
bash step3.sh
3232235818   -> 192.168.1.42
4294967295   -> 255.255.255.255
0            -> 0.0.0.0

n >> 24 shifts the top byte down into the low bits, and & 255 keeps only that byte. Do it four times with a different shift each time and the four octets fall out in order.

Step 4: The mask, from a prefix

A mask is a run of ones followed by a run of zeros. A /24 is twenty-four ones and eight zeros.

cat > step4.sh <<'EOF'
mask_from_prefix() {
  printf '%d' $(( (0xffffffff << (32 - $1)) & 0xffffffff ))
}

int_to_ip() {
  local n=$1
  printf '%d.%d.%d.%d' $(( (n >> 24) & 255 )) $(( (n >> 16) & 255 )) $(( (n >> 8) & 255 )) $(( n & 255 ))
}

for p in 24 12 31 32 0; do
  printf '/%-3s -> %s\n' "$p" "$(int_to_ip "$(mask_from_prefix "$p")")"
done
EOF
bash step4.sh
/24  -> 255.255.255.0
/12  -> 255.240.0.0
/31  -> 255.255.255.254
/32  -> 255.255.255.255
/0   -> 0.0.0.0

Start with all thirty-two bits set, shift left by the number of host bits, and you have the mask. The & 0xffffffff at the end is doing real work: shell arithmetic on 64-bit integers would otherwise leave the top bits set, and the mask would be wrong.

Now 255.240.0.0 stops looking arbitrary. It is twelve ones and twenty zeros, and twelve is the prefix length.

Step 5: Network with an AND, broadcast with an OR

The network address has every host bit set to zero, so it is the address ANDed with the mask. The broadcast address has every host bit set to one, so it is the network ORed with the inverse of the mask.

cat > step5.sh <<'EOF'
ip_to_int() { printf '%s' "$1" | awk -F. '{ printf "%.0f", ($1 * 16777216) + ($2 * 65536) + ($3 * 256) + $4 }'; }
int_to_ip() { local n=$1; printf '%d.%d.%d.%d' $(( (n >> 24) & 255 )) $(( (n >> 16) & 255 )) $(( (n >> 8) & 255 )) $(( n & 255 )); }
mask_from_prefix() { printf '%d' $(( (0xffffffff << (32 - $1)) & 0xffffffff )); }

n=$(ip_to_int 192.168.1.42)
m=$(mask_from_prefix 24)
net=$(( n & m ))
bc=$(( net | (~m & 0xffffffff) ))

printf 'address   %s\n' "$(int_to_ip "$n")"
printf 'netmask   %s\n' "$(int_to_ip "$m")"
printf 'network   %s\n' "$(int_to_ip "$net")"
printf 'broadcast %s\n' "$(int_to_ip "$bc")"
EOF
bash step5.sh
address   192.168.1.42
netmask   255.255.255.0
network   192.168.1.0
broadcast 192.168.1.255

~m flips every bit, turning the mask into its inverse, which is the wildcard mask. ANDing the address with the mask clears the host bits and leaves the network. ORing the network with the wildcard sets them all and gives the broadcast.

Step 6: How many hosts, and the two special cases

The count is two to the power of the host bits. Then two addresses come off, because the network and broadcast addresses cannot be given to a machine. Except when they can.

cat > step6.sh <<'EOF'
ip_to_int() { printf '%s' "$1" | awk -F. '{ printf "%.0f", ($1 * 16777216) + ($2 * 65536) + ($3 * 256) + $4 }'; }
int_to_ip() { local n=$1; printf '%d.%d.%d.%d' $(( (n >> 24) & 255 )) $(( (n >> 16) & 255 )) $(( (n >> 8) & 255 )) $(( n & 255 )); }
mask_from_prefix() { printf '%d' $(( (0xffffffff << (32 - $1)) & 0xffffffff )); }

for spec in 192.168.1.42/24 10.0.0.5/31 192.0.2.9/32; do
  addr=${spec%%/*}
  prefix=${spec#*/}
  n=$(ip_to_int "$addr")
  m=$(mask_from_prefix "$prefix")
  net=$(( n & m ))
  bc=$(( net | (~m & 0xffffffff) ))
  total=$(( 2 ** (32 - prefix) ))

  if [ "$prefix" -eq 32 ]; then
    usable=1; first=$n; last=$n
  elif [ "$prefix" -eq 31 ]; then
    usable=2; first=$net; last=$bc
  else
    usable=$(( total - 2 )); first=$(( net + 1 )); last=$(( bc - 1 ))
  fi

  printf '%-18s network %-15s broadcast %-15s total %-8s usable %-6s first %-15s last %s\n' \
    "$spec" "$(int_to_ip "$net")" "$(int_to_ip "$bc")" "$total" "$usable" "$(int_to_ip "$first")" "$(int_to_ip "$last")"
done
EOF
bash step6.sh
192.168.1.42/24    network 192.168.1.0     broadcast 192.168.1.255   total 256      usable 254    first 192.168.1.1     last 192.168.1.254
10.0.0.5/31        network 10.0.0.4        broadcast 10.0.0.5        total 2        usable 2      first 10.0.0.4        last 10.0.0.5
192.0.2.9/32       network 192.0.2.9       broadcast 192.0.2.9       total 1        usable 1      first 192.0.2.9       last 192.0.2.9

Look at the /31. It has two addresses and both of them are usable, because there is no room to reserve a network or a broadcast address. RFC 3021 defines this, and it is exactly what you want on a point-to-point link between two routers. The /32 is a single address, and it is both the network and the host. Many calculators subtract two from a /31 and cheerfully report zero usable hosts, which is wrong.

Step 7: Accept a mask as well as a prefix

People write 255.255.255.0 as often as they write /24, so accept both. Going from a mask back to a prefix is a check as well as a conversion, because not every four numbers are a valid mask.

cat > step7.sh <<'EOF'
ip_to_int() { printf '%s' "$1" | awk -F. '{ printf "%.0f", ($1 * 16777216) + ($2 * 65536) + ($3 * 256) + $4 }'; }

prefix_from_mask() {
  local m=$1
  if [ "$m" -eq 0 ]; then
    printf '0'
    return 0
  fi
  if [ $(( m + (m & -m) )) -ne 4294967296 ]; then
    return 1
  fi
  local wild=$(( (~m) & 0xffffffff ))
  local size=$(( wild + 1 )) p=32
  while [ "$size" -gt 1 ]; do
    size=$(( size / 2 ))
    p=$(( p - 1 ))
  done
  printf '%d' "$p"
}

for mask in 255.255.255.0 255.240.0.0 255.0.255.0; do
  printf '%-16s -> ' "$mask"
  if p=$(prefix_from_mask "$(ip_to_int "$mask")"); then
    echo "/$p"
  else
    echo "not a run of ones then zeros"
  fi
done
EOF
bash step7.sh
255.255.255.0    -> /24
255.240.0.0      -> /12
255.0.255.0      -> not a run of ones then zeros

The validity check is the interesting line. For a real mask, adding the mask to its own lowest set bit carries all the way out and lands exactly on 2^32. For a mask with a hole in it, like 255.0.255.0, the carry stops short and the sum is smaller, so the test fails. m & -m is the trick for isolating the lowest set bit.

Step 8: Arguments, usage and errors

A calculator that prints a stack trace when you mistype an address is not finished. Accept ADDR/PREFIX, ADDR PREFIX and ADDR NETMASK, and complain clearly otherwise.

cat > step8.sh <<'EOF'
prog=${0##*/}

valid_ip() {
  awk -F. '
    NF != 4 { exit 1 }
    {
      for (i = 1; i <= 4; i++) {
        if ($i !~ /^[0-9]+$/) exit 1
        if ($i + 0 > 255) exit 1
      }
    }
  ' <<< "$1"
}

case "$1" in
  */*) addr=${1%%/*}; prefix_arg=${1#*/} ;;
  *)   addr=$1; prefix_arg=${2:-} ;;
esac

if ! valid_ip "$addr"; then
  printf '%s: not a valid IPv4 address: %s\n' "$prog" "$addr" >&2
  exit 1
fi

case "$prefix_arg" in
  *[!0-9]*|'') printf '%s: prefix must be a number from 0 to 32: %s\n' "$prog" "$prefix_arg" >&2; exit 1 ;;
  *) if [ "$prefix_arg" -gt 32 ]; then printf '%s: prefix must be 0 to 32\n' "$prog" >&2; exit 1; fi ;;
esac

printf 'address %s, prefix /%s\n' "$addr" "$prefix_arg"
EOF

bash step8.sh 999.1.1.1/24; echo "  exit $?"
bash step8.sh 192.168.1.1/33; echo "  exit $?"
step8.sh: not a valid IPv4 address: 999.1.1.1
  exit 1
step8.sh: prefix must be a number from 0 to 32: 33
  exit 1

valid_ip checks two things the eye misses: that there are exactly four parts, and that each one is digits only and no larger than 255. The $i !~ /^[0-9]+$/ test is what stops 1.2.3.4x being accepted.

The whole script

Save this as subnetcalc.sh and make it executable with chmod +x subnetcalc.sh. Nothing is left out.

#!/usr/bin/env bash
# subnetcalc.sh - work out the figures for an IPv4 subnet.

set -u

prog=${0##*/}

usage() {
  cat <<'USAGE'
Usage: subnetcalc.sh ADDRESS/PREFIX
       subnetcalc.sh ADDRESS PREFIX
       subnetcalc.sh ADDRESS NETMASK

Examples:
  subnetcalc.sh 192.168.1.42/24
  subnetcalc.sh 192.168.1.42 24
  subnetcalc.sh 192.168.1.42 255.255.255.0

ADDRESS is an IPv4 address. PREFIX is 0 to 32. NETMASK is a dotted
mask such as 255.255.255.0.
USAGE
}

# Turn a dotted quad into one 32 bit integer.
ip_to_int() {
  printf '%s' "$1" | awk -F. '{ printf "%.0f", ($1 * 16777216) + ($2 * 65536) + ($3 * 256) + $4 }'
}

# Turn one 32 bit integer back into a dotted quad.
int_to_ip() {
  local n=$1
  printf '%d.%d.%d.%d' $(( (n >> 24) & 255 )) $(( (n >> 16) & 255 )) $(( (n >> 8) & 255 )) $(( n & 255 ))
}

# True when the argument is four numbers, each 0 to 255.
valid_ip() {
  awk -F. '
    NF != 4 { exit 1 }
    {
      for (i = 1; i <= 4; i++) {
        if ($i !~ /^[0-9]+$/) exit 1
        if ($i + 0 > 255) exit 1
      }
    }
  ' <<< "$1"
}

# Turn a prefix length into a mask, as an integer.
mask_from_prefix() {
  printf '%d' $(( (0xffffffff << (32 - $1)) & 0xffffffff ))
}

# Turn a mask into a prefix length, or fail if the mask has a hole.
prefix_from_mask() {
  local m=$1
  if [ "$m" -eq 0 ]; then
    printf '0'
    return 0
  fi
  if [ $(( m + (m & -m) )) -ne 4294967296 ]; then
    return 1
  fi
  local wild=$(( (~m) & 0xffffffff ))
  local size=$(( wild + 1 )) p=32
  while [ "$size" -gt 1 ]; do
    size=$(( size / 2 ))
    p=$(( p - 1 ))
  done
  printf '%d' "$p"
}

if [ $# -eq 0 ]; then
  usage >&2
  exit 2
fi

case "$1" in
  -h|--help)
    usage
    exit 0
    ;;
  -*)
    printf '%s: unknown option %s\n' "$prog" "$1" >&2
    printf 'Try %s --help for usage.\n' "$prog" >&2
    exit 2
    ;;
esac

case "$1" in
  */*)
    addr=${1%%/*}
    prefix_arg=${1#*/}
    ;;
  *)
    addr=$1
    prefix_arg=${2:-}
    ;;
esac

if [ -z "$prefix_arg" ]; then
  printf '%s: no prefix or netmask given\n' "$prog" >&2
  printf 'Try %s --help for usage.\n' "$prog" >&2
  exit 2
fi

if ! valid_ip "$addr"; then
  printf '%s: not a valid IPv4 address: %s\n' "$prog" "$addr" >&2
  exit 1
fi

case "$prefix_arg" in
  *.*)
    if ! valid_ip "$prefix_arg"; then
      printf '%s: not a valid netmask: %s\n' "$prog" "$prefix_arg" >&2
      exit 1
    fi
    m=$(ip_to_int "$prefix_arg")
    if ! prefix=$(prefix_from_mask "$m"); then
      printf '%s: netmask is not a run of ones then zeros: %s\n' "$prog" "$prefix_arg" >&2
      exit 1
    fi
    ;;
  *[!0-9]*|'')
    printf '%s: prefix must be a number from 0 to 32: %s\n' "$prog" "$prefix_arg" >&2
    exit 1
    ;;
  *)
    prefix=$prefix_arg
    if [ "$prefix" -gt 32 ]; then
      printf '%s: prefix must be a number from 0 to 32: %s\n' "$prog" "$prefix_arg" >&2
      exit 1
    fi
    ;;
esac

n=$(ip_to_int "$addr")
m=$(mask_from_prefix "$prefix")
net=$(( n & m ))
bc=$(( net | (~m & 0xffffffff) ))
wild=$(( (~m) & 0xffffffff ))
total=$(( 2 ** (32 - prefix) ))

if [ "$prefix" -eq 32 ]; then
  usable=1
  first=$n
  last=$n
elif [ "$prefix" -eq 31 ]; then
  usable=2
  first=$net
  last=$bc
else
  usable=$(( total - 2 ))
  first=$(( net + 1 ))
  last=$(( bc - 1 ))
fi

printf 'address      %s\n' "$addr"
printf 'prefix       /%d\n' "$prefix"
printf 'network      %s\n' "$(int_to_ip "$net")"
printf 'broadcast    %s\n' "$(int_to_ip "$bc")"
printf 'netmask      %s\n' "$(int_to_ip "$m")"
printf 'wildcard     %s\n' "$(int_to_ip "$wild")"
printf 'first host   %s\n' "$(int_to_ip "$first")"
printf 'last host    %s\n' "$(int_to_ip "$last")"
printf 'addresses    %d\n' "$total"
printf 'usable       %d\n' "$usable"

Using it

An ordinary home network:

./subnetcalc.sh 192.168.1.42/24
address      192.168.1.42
prefix       /24
network      192.168.1.0
broadcast    192.168.1.255
netmask      255.255.255.0
wildcard     0.0.0.255
first host   192.168.1.1
last host    192.168.1.254
addresses    256
usable       254

A /31, where both addresses are usable:

./subnetcalc.sh 10.0.0.5/31
address      10.0.0.5
prefix       /31
network      10.0.0.4
broadcast    10.0.0.5
netmask      255.255.255.254
wildcard     0.0.0.1
first host   10.0.0.4
last host    10.0.0.5
addresses    2
usable       2

A /32, a single host:

./subnetcalc.sh 192.0.2.9/32
address      192.0.2.9
prefix       /32
network      192.0.2.9
broadcast    192.0.2.9
netmask      255.255.255.255
wildcard     0.0.0.0
first host   192.0.2.9
last host    192.0.2.9
addresses    1
usable       1

A /12, which does not end on a byte boundary:

./subnetcalc.sh 172.16.5.5/12
address      172.16.5.5
prefix       /12
network      172.16.0.0
broadcast    172.31.255.255
netmask      255.240.0.0
wildcard     0.15.255.255
first host   172.16.0.1
last host    172.31.255.254
addresses    1048576
usable       1048574

A mask instead of a prefix gives the same answer:

./subnetcalc.sh 192.168.1.42 255.255.255.0
address      192.168.1.42
prefix       /24
network      192.168.1.0

And the errors are plain:

./subnetcalc.sh 192.168.1.42 255.0.255.0
subnetcalc.sh: netmask is not a run of ones then zeros: 255.0.255.0

To check the subnet you are on right now, feed it your own address:

./subnetcalc.sh "$(ip -o -4 addr show | awk 'NR==1{split($4,a,"/"); print a[1]"/"a[2]}')"

Quick reference

PieceWhat it does
ip -o -4 addr showyour own addresses and prefixes, one per line
awk -F.split a dotted quad on the dots
printf '%.0f'stop awk printing a large integer in scientific notation
n >> 24 and & 255pull one byte out of the number
0xffffffff << (32 - p)build a mask from a prefix length
n & mthe network address
net | (~m)the broadcast address
2 ** (32 - p)how many addresses the block holds
m & -misolate the lowest set bit, used to validate a mask
/31 and /32the two cases where nothing is reserved

Next steps

Test yourself on this

4 questions and 1 task where you run a real command, with the reasoning explained either way.

Start