#!/bin/bash
# -*- mode: sh; sh-basic-offset: 2; indent-tabs-mode: nil; -*-
# vim:set ft=sh et sw=2 ts=2:
#
# ip-mock - ip command wrapper/replacement for testing
# Author: Scott Shambarger <devel@shambarger.net>
#
# Copyright (C) 2021 Scott Shambarger
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
VERSION=0.5.1

IP_MOCK_ADDRS=${IP_MOCK_ADDRS:-conf/ip-mock-addrs}

IP_MOCK_FAIL=${IP_MOCK_FAIL-}

err() {
  local IFS; unset IFS
  printf >&2 "%s\n" "$*"
}

fail() {
  local -i rc=0
  [[ $1 != 0 ]] && { printf 2>/dev/null -v rc "%d" "$1" || rc=1; }
  shift
  [[ $1 ]] && { if [[ $rc == 0 ]]; then echo "$@"; else err "$@"; fi; }
  exit "$rc"
}

verbose() {
  [[ $VERBOSE ]] && err "$@"
}

usage() {
  err "Usage: ${0##*/} [ OPTIONS ] OBJECT { COMMAND | help }"
  err "where  OBJECT := { address }"
  err "       OPTIONS := { -4 | -6 | -v | -V[ersion] }"
  exit 1
}

# State file format:
#  dev <device> [ <mtu> [ <state> ] ]
#  addr <ip4> [ <valid> [ <pref> [ <scope> [ <conf-flags> ] ] ] ]
#  addr6 <ip6> [ <valid> [ <pref> [ <scope> [ <conf-flags> ] ] ] ]
# <valid|pref> = <#>sec | forever
# <scope> = host | link | global | <num>
# <conf-flags> = noprefixroute nodad tentative...
# <state> = BROADCAST,MULTICAST,UP,LOWER_UP
ADDRS=()
DEVS=()
read_addr_state() { # <filename>
  local file=$1
  [[ -f $file ]] || { verbose "State file $file not found"; return 0; }
  verbose "Loading address state file $file"
  DEVS=("lo 65536 LOOPBACK,UP,LOWER_UP")
  ADDRS=("lo addr 127.0.0.1/8 forever forever host lo")
  ADDRS+=("lo addr6 ::1/128 forever forever host")
  local IFS=$'\n' line dev
  while read -r line || [[ $line ]]; do
    unset IFS
    [[ "$line" =~ ^# ]] && continue
    # shellcheck disable=SC2086
    set -- $line
    case $1 in
      dev) dev=$2; shift 2; DEVS+=("$dev $*");;
      addr|addr6)
        [[ $dev ]] || continue
        ADDRS+=("$dev $*");;
    esac
  done < "$file"
  return 0
}

usage_addr() {
  local ip=${0##*/}
  err "Usage: $ip address {add|change|replace} IFADDR dev IFNAME [ LIFETIME ]"
  err "       $ip address del IFADDR dev IFNAME"
  err "       $ip address [ show [ dev IFNAME ] [ scope SCOPE-ID ] ]"
  err "IFADDR := PREFIX [ scope SCOPE-ID ]"
  err "SCOPE-ID := [ host | link | global | NUMBER ]"
  err "LIFETIME := [ valid_lft LFT ] [ preferred_lft LFT ]"
  err "LFT := forever | SECONDS"
  exit 255
}

addr_show_dev() {
  [[ $dev == "$show_dev" ]] && return
  show_dev=$dev
  echo "$num: $dev: <$state> mtu $mtu qdisc $qdisc state $istate group default qlen 1000"
  if [[ $IPV4 && $IPV6 ]]; then
    if [[ $dev = lo ]]; then
      echo -n "    link/loopback 00:00:00:00:00:00"
    else
      [[ $num -lt 10 ]] && b="0$num" || b="$num"
      echo -n "    link/ether 08:00:27:1b:ff:$b"
    fi
    if [[ $state =~ BROADCAST ]]; then
      echo " brd ff:ff:ff:ff:ff:ff"
    else
      echo " brd 00:00:00:00:00:00"
    fi
  fi
  return 0
}

addr_show() {
  local d e dev num=0 mtu type a b valid pref scope state istate qdisc filter
  local show_dev=''
  for e in "${ADDRS[@]}"; do
    # shellcheck disable=SC2086
    set -- $e
    [[ $1 && $2 ]] || continue
    if [[ $dev != "$1" ]]; then
      ((num++))
      dev=$1
      [[ $DEV ]] && [[ $DEV != "$dev" ]] && continue
      mtu=1500; state="BROADCAST,MULTICAST,UP,LOWER_UP"
      for d in "${DEVS[@]}"; do
        # shellcheck disable=SC2086
        set -- $d
        if [[ $dev == "$1" ]]; then
          mtu=${2:-1500}; state="${3:-BROADCAST,MULTICAST,UP,LOWER_UP}"
          break
        fi
      done
      istate=UP; qdisc=fq_codel
      [[ $dev == lo ]] && { istate=UNKNOWN; qdisc=noqueue; }
      # shellcheck disable=SC2086
      set -- $e
    fi
    [[ $DEV ]] && [[ $DEV != "$dev" ]] && continue
    type=$2 a=$3 valid=${4:-forever} pref=${5-forever} scope=${6-global}
    [[ $SCOPE ]] && [[ $SCOPE != "$scope" ]] && continue
    shift 3; shift; shift; shift
    for filter in ${FILTER[@]+"${FILTER[@]}"}; do
      if [[ $filter =~ ^- ]]; then
        [[ $* =~ (^| )${filter#-}($| ) ]] && continue 2
      elif ! [[ $* =~ (^| )${filter#-}($| ) ]]; then
        continue 2
      fi
    done
    addr_show_dev
    if [[ $IPV4 ]] && [[ $type == addr ]]; then
      # poor-man broadcast hack
      [[ $state =~ BROADCAST ]] && b="brd ${a%.*}.255 " || b=
      echo "    inet $a ${b}scope $scope $*"
      echo "       valid_lft $valid preferred_lft $pref"
    elif [[ $IPV6 ]] && [[ $type == addr6 ]]; then
      echo "    inet6 $a scope $scope $*"
      echo "       valid_lft $valid preferred_lft $pref"
    fi
  done
  # if we never set mtu, filtered device was never found
  [[ $DEV && ! $mtu ]] && fail 1 "Device \"$DEV\" does not exist."
}

OBJECT='' COMMAND=show IPV4=1 IPV6=1 DEV='' SCOPE='' IP4='' IP6=''
VERBOSE='' VALID_LT='' PREF_LT='' FILTER=()
parse_args() {
  local state=object nomatch
  for arg in "$@"; do
    case $state in
      object)
        case $arg in
          addr|address) OBJECT=addr; state=addr ;;
          help) usage ;;
          -6) IPV4='' ;;
          -4) IPV6='' ;;
          -v) VERBOSE=1 ;;
          -V|-Version) echo "${0##*/}, $VERSION"; exit 0; ;;
          +fail=*) IP_MOCK_FAIL=${arg#+fail=} ;;
          +fail*) IP_MOCK_FAIL=1 ;;
          -*) : ;; # ignore other options
          *) fail 1 "Object \"$arg\" is unknown, try \"ip help\"" ;;
        esac
        ;;
      addr)
        case $arg in
          add|change|replace|del|delete) COMMAND=$arg; state=ifaddr ;;
          show) COMMAND=$arg; state=options ;;
          help) usage_addr ;;
          *) fail 255 "Command \"$arg\" is unknown, try \"ip address help\"" ;;
        esac
        ;;
      dev) DEV=$arg; state=options ;;
      scope)
        if [[ $arg =~ ^(host|link|global|[0-9]+)$ ]]; then
          SCOPE=$arg; state=options
        else
          fail 255 "Error: argument \"$arg\" is wrong: invalid scope value."
        fi
        ;;
      valid_lft|preferred_lft)
        if [[ $arg =~ ^([0-9]+)$ ]] || [[ $arg == forever ]]; then
          [[ $state == valid_lft ]] && VALID_LT=$arg || PREF_LT=$arg
          state=options
        else
          fail 255 "Error: argument \"$arg\" is wrong: $state value"
        fi
        ;;
      ifaddr)
        if [[ $arg =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{1,4}(/[0-9]+)?$ ]]; then
          IP6=$arg; IPV4=''
        elif [[ $arg =~ ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|(/[0-9]+)?$)){4}$ ]]; then
          IP4=$arg; IPV6=''
        else
          fail 1 "Error: any valid prefix is expected rather than \"$arg\""
        fi
        state=options
        ;;
      options)
        if [[ ${OBJECT} == addr ]]; then
          case $arg in
            dev|scope|valid_lft|preferred_lft)
              state=$arg
              ;;
            *)
              nomatch=''
              if [[ $arg =~ ^(home|mngtmpaddr|nodad|optimstic|noprefixroute|autojoin)$ ]]; then
                FILTER+=("$arg") # match confflag
              elif [[ $COMMAND == show ]]; then
                if [[ $arg =~ ^[-]?(tentative|deprecated|dadfailed)$ ]]; then
                  FILTER+=("$arg")
                elif [[ $arg =~ ^(permanent|dynamic|secondary|primary|temporary)$ ]]; then
                  FILTER+=("$arg")
                else
                  nomatch=1
                fi
              else
                nomatch=1
              fi
              if [[ $nomatch ]]; then
                fail 255 "Error: either \"local\" is duplicate, or \"$arg\" is a garbage."
              fi
              ;;
          esac
        else
          fail 255 "Error: either \"local\" is duplicate, or \"$arg\" is a garbage."
        fi
        ;;
    esac
  done

  case $OBJECT in
    addr)
      case $COMMAND in
        add|del|delete)
          [[ $DEV ]] ||
            fail 1 "Not enough information: \"dev\" argument is required."
          [[ $IP6 || $IP4 ]] ||
            fail 2 "RTNETLINK answers: Operation not supported"
          ;;
      esac
      ;;
  esac
}

[[ ${MOCK_DEBUG} ]] && { err "${0##*/}" "$@"; VERBOSE=1; }

parse_args "$@"

[[ ${IP_MOCK_FAIL-} || ${IP_MOCK_OUTPUT-} ]] && {
  args='' arg=''; for arg in "$@"; do args+="${args:+ }'${arg}'"; done
  fail "${IP_MOCK_FAIL:-0}" \
       "${IP_MOCK_OUTPUT+${IP_MOCK_OUTPUT/@ARGS@/${args}}}"
}

verbose "OBJECT=$OBJECT COMMAND=$COMMAND"
verbose "IPV4=$IPV4 IP4=$IP4"
verbose "IPV6=$IPV6 IP6=$IP6"
verbose "VALID_LT=$VALID_LT PREF_LT=$PREF_LT"
verbose "DEV=$DEV SCOPE=$SCOPE"

case $OBJECT in
  addr)
    read_addr_state "$IP_MOCK_ADDRS"
    case $COMMAND in
      add|change|replace|del|delete)
        [[ ${MOCK_ECHO} ]] && {
          out='ip' arg=''
          for arg in "$@"; do out+=" '${arg}'"; done
          printf '%s\n' "${out}"
        }
        ;;
      show) addr_show;;
      *) fail 1 "Command \"$COMMAND\" not implemented"
    esac
    ;;
  *) fail 1 "Object \"$OBJECT\" not implemented"
esac

exit 0
