Manage Ipset

Requirements

Add in /etc/ferm/ferm.conf :

{
    @hook pre "/usr/local/bin/restore_ipset.sh";
}
[...]
domain ip chain (INPUT) {
       interface (eth0) proto tcp mod set set whitelist src ACCEPT;
       interface (eth0) mod set set blacklist src DROP;
}

Scripts

/usr/local/bin/restore_ipset.sh :

#!/bin/bash
# vim: ai ts=2 sw=2 et sts=2 ft=sh

set -euo pipefail

typeset -r IPSET_CONF="$HOME/.ipset.conf"

/sbin/ipset create -exist whitelist hash:net
/sbin/ipset create -exist blacklist hash:net

if [[ -f "${IPSET_CONF}" ]]; then
  if [[ $(/sbin/ipset list -name) =~ blacklist_tmp ]]; then
    /sbin/ipset destroy blacklist_tmp
  fi
  if [[ $(/sbin/ipset list -name) =~ whitelist_tmp ]]; then
    /sbin/ipset destroy whitelist_tmp
  fi
  /sbin/ipset restore -exist < "${IPSET_CONF}"
  /sbin/ipset swap blacklist_tmp blacklist
  /sbin/ipset swap whitelist_tmp whitelist
fi
/sbin/ipset destroy blacklist_tmp
/sbin/ipset destroy whitelist_tmp

[[ $? == 0 ]] && \
echo 'IP sets has been updated.'

/usr/local/bin/manage_ipset :

#!/bin/bash
# vim: ai ts=2 sw=2 et sts=2 ft=sh
# guisam

# Set environnement
set -uo pipefail
trap 'echo -ne " ${RED}Crtl-C is disable.${NC}"' SIGINT
trap 'echo -ne " ${RED}Ctrl-Z is disable.${NC}"' SIGTSTP
trap 'echo -e "${GREEN}\n${PROJECT_LOGO} ${COPYLEFT_LOGO} ${YEAR} ${PROJECT_NAME}${TRADE_MARK_LOGO}, Inc.${NC}"' EXIT

# Variables
typeset -r PROJECT_NAME="Guisam"
TAB="  "
RED=""
GREEN=""
YELLOW=""
BGREEN=""
NC=""
WARNING_LOGO="+"
ERROR_LOGO="+"
OK_LOGO="+"
GLOBAL_LOGO="+"
typeset -r TRADE_MARK_LOGO="\xe2\x84\xa2"
typeset -r COPYLEFT_LOGO="\xf0\x9f\x84\xaf"
typeset -r PROJECT_LOGO="\xf0\x9f\x9c\x87"
typeset -r HEADER="+ ${PROJECT_NAME} manage ipset interface +"
typeset -r YEAR=$(date +%Y)
typeset -r IP_PATTERN="\b(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}\b"
typeset -r IP_CIDR_PATTERN="${IP_PATTERN}(\/(3[0-2]|[1-2][0-9]|[0-9]))"
typeset -r IPSET_CONF="./conf_directory/ipset.conf"
typeset -A MENU
typeset -A SR_ACTION
MENU[1l]="List current IP sets."
MENU[2a]="Add IP adress/range to IP sets."
MENU[3d]="Delete IP adress/range from IP sets."
MENU[4s]="Save IP sets to ipset.conf."
MENU[5r]="Restore IP sets from ipset.conf."
MENU[6h]="Information page."
MENU[7q]="Quit."
SR_ACTION["save"]="ipset.conf"
SR_ACTION["restore"]="IP sets"

# Functions
add_del_ip_net ()
{
  ad_message=""
  local ip_net="$1"
  local working_set="$2"
    ip_list_check=$("${IPSET}" list "${working_set}" | grep "${ip_net}")
    [[ "${ip_list_check}" == "${ip}" && "${action_ad}" == "add" ]] && \
      ad_message="${ad_message}${TAB}${WARNING_LOGO} ${YELLOW}${ip_net}${NC} is already in ${working_set} set.\n"
    [[ "${ip_list_check}" == "" && "${action_ad}" == "del" ]] && \
      ad_message="${ad_message}${TAB}${WARNING_LOGO} ${YELLOW}${ip_net}${NC} is not in ${working_set} set.\n"
    [[ "${ip_list_check}" == "" && "${action_ad}" == "add" ]] && \
      if "${IPSET}" "${action_ad}" "${working_set}" "${ip_net}"; then
        ad_message="${ad_message}${TAB}${OK_LOGO} ${GREEN}${ip_net}${NC} has been added to ${working_set} set.\n"
      fi
    [[ "${ip_list_check}" == "${ip_net}" && "${action_ad}" == "del" ]] && \
      if "${IPSET}" "${action_ad}" "${working_set}" "${ip_net}"; then
        ad_message="${ad_message}${TAB}${OK_LOGO} ${GREEN}${ip_net}${NC} has been deleted from ${working_set} set.\n"
      fi
}

add_del ()
{
  local working_set="$1"
  add_del_message=""
  for ip in ${response}; do
    if [[ "${ip}" =~ ^${IP_PATTERN}$ ]]; then
      add_del_ip_net "${ip}" "${working_set}" ; add_del_message="${add_del_message}${ad_message}"
    elif [[ "${ip}" =~ ^${IP_CIDR_PATTERN}$ ]]; then
      network=$("${IPCALC}" "${ip}" | awk '$1 == "Network:" {print $2}')
      if [[ "${network}" =~ ^0.0.0.0 ]]; then
        add_del_message="${add_del_message}${TAB}${ERROR_LOGO} ${RED}${ip}${NC} is 0.0.0.0 network.\n"
      else
        add_del_ip_net "${network}" "${working_set}" ; add_del_message="${add_del_message}${ad_message}"
      fi
    else
      add_del_message="${add_del_message}${TAB}${ERROR_LOGO} ${RED}${ip}${NC} is not a well formed IP address/range.\n"
    fi
  done
}

before_main ()
{
  [[ $(id -u) -ne 0 ]] && screen_error "sudo is required."
  IPSET=$(command -v ipset)
  [[ ! -x "${IPSET}" ]] && screen_error "ipset command not found."
  IPCALC=$(command -v ipcalc)
  [[ ! -x "${IPCALC}" ]] && screen_error "ipcalc command not found."
  CHECK_SETS=$("${IPSET}" list -name | tr -d "\n")
  SETS_PATTERN=$("${IPSET}" list -name | sed ':a;N;$!ba;s/\n/|/g')
  ADD_DEL_PATTERN="^(add|del) (${SETS_PATTERN})_tmp (${IP_PATTERN}|${IP_CIDR_PATTERN})$"
  CREATE_PATTERN="^create (${SETS_PATTERN})_tmp hash:net family inet hashsize 1024 maxelem 65536$"
  [[ ! "${CHECK_SETS}" =~ blacklistwhitelist|whitelistblacklist ]] && \
    screen_error "blacklist and whitelist sets are required."
  mapfile -t SETS < <("${IPSET}" list -name)
  GLOBAL_COMMENT="${TAB}${GLOBAL_LOGO} usage.\n${TAB}Interface to use ipset.\
  \n${TAB}See ipset man for more informations on ipset."
  OK_COMMENT="\n\n${TAB}${OK_LOGO} ok.\n${TAB}Check or action are successful."
  WARNING_COMMENT="\n\n${TAB}${WARNING_LOGO} warning.\n${TAB}Save/restore:\n${TAB} blank, commented line,\
  \n${TAB} removed from ipset.conf if save,\n${TAB} ignored if restore.\
  \n${TAB}Add/del:\n${TAB} ignored if already in a set when add,\n${TAB} ignored if not in a set when del."
  ERROR_COMMENT="\n\n${TAB}${ERROR_LOGO} error.\n${TAB}Save/restore:\n${TAB} malformed line,\
  \n${TAB}  removed from ipset.conf if save,\n${TAB}  restore is unvalailable;\
  \n${TAB} reference to an undefined set.\
  \n${TAB}Add/del:\n${TAB} network 0.0.0.0 is forbiden."
}

check_conf_file_syntax ()
{
  local count=0
  syntax_message=""
  syntax_warning=0
  syntax_error=0
  created_set=""
  while read -r line ; do (( count += 1 ))
    if [[ "$line" =~ ${CREATE_PATTERN} ]]; then
      [[ "${created_set}" =~ ${line:7:13} ]] || created_set="${created_set} ${line:7:13}"
      continue
    fi
    if [[ "$line" =~ ${ADD_DEL_PATTERN} ]]; then
      if [[ "${created_set}" =~ ${line:4:13} ]]; then
        continue
      else
        syntax_message="${syntax_message}${TAB}${ERROR_LOGO} line ${count} ${line:4:13} is not created.\n${TAB}   -> ${RED}${line}${NC}\n"
        (( syntax_error +=1 )) ; continue
      fi
    fi
    if [[ "$line" =~ (^$|[[:blank:]]*#.*$) ]]; then
      syntax_message="${syntax_message}${TAB}${WARNING_LOGO} line ${count} is commented or blank.\n"
      (( syntax_warning +=1 )) ; continue
    fi
    syntax_message="${syntax_message}${TAB}${ERROR_LOGO} line ${count} is malformed.\n${TAB}   -> ${RED}${line}${NC}\n"
    (( syntax_error +=1 )) ; continue
  done < "${IPSET_CONF}"
  if [[ "${syntax_warning}" == 0 && "${syntax_error}" == 0 ]]; then
    syntax_message="${syntax_message}${TAB}${OK_LOGO} ipset.conf syntax is validated."
  else
    check_number "${TAB}Ipset.conf syntax issue" "(( ${syntax_warning} + ${syntax_error}  ))" && \
    syntax_message="${number_message}\n\n${syntax_message}\n"
    if [[ "${syntax_error}" -gt 0 ]]; then
      check_number "${TAB}${ERROR_LOGO} ${RED}${syntax_error} error" "${syntax_error}" && \
        syntax_message="${syntax_message}${number_message}\n"
    fi
    if [[ "${syntax_warning}" -gt 0 ]]; then
      check_number "${TAB}${WARNING_LOGO} ${YELLOW}${syntax_warning} warning" "${syntax_warning}" && \
        syntax_message="${syntax_message}${number_message}"
    fi
  fi
}

check_number ()
{
  number_message="$1"
  local number="$2"
  [[ "${number}" -gt 1 ]] && number_message="${number_message}s"
  number_message="${number_message}.${NC}"
}

check_option_tab ()
{
    local TAB_ARG=$"$1"
    if [[ $TAB_ARG == [0-8] ]]; then
      TAB=""
      for (( i=1; i<=TAB_ARG; i++ )); do
        TAB="${TAB} "
      done
    fi
}

menu_header ()
{
  [[ $(set | grep "^header_message") =~ ^header_message ]] && return
  local line=""
  for (( i=1; i<=${#HEADER}; i++ )); do line="${line}-"; done
  header_message="${GREEN}${line}\n${HEADER}\n${line}${NC}"
}

menu_main ()
{
  [[ $(set | grep "^main_message") =~ ^main_message ]] && return
  main_message=""
  for i in "${!MENU[@]}"
  do
    menu_header_first="${BGREEN}${i:0:1}${NC} or ${BGREEN}${i:1:1}${NC}"
    main_message="${main_message}${TAB}${menu_header_first}${NC} ... ${MENU[$i],}\n"
  done
}

menu_title ()
{
  local screen_number="$1"
  title_message="${header_message}\n\n${GREEN}+${NC} ${MENU[${screen_number}]}\n"
}

parse_options ()
{
  while getopts "ht:ce" OPTION
  do
    case ${OPTION} in
      h)
        usage
        exit 0
        ;;
      t)
        check_option_tab "${OPTARG}"
        ;;
      c)
        RED="\033[0;31m"
        GREEN="${RED}\033[0;32m"
        YELLOW="\033[0;33m"
        BGREEN="\033[1;32m"
        NC="\033[0m"
        ;;
      e)
        WARNING_LOGO="\xf0\x9f\x9a\xa7"
        ERROR_LOGO="\xf0\x9f\x98\xa1"
        OK_LOGO="\xe2\x9c\x85"
        GLOBAL_LOGO="\xf0\x9f\x8c\x8d"
        ;;
      *)
        screen_error "invalid option or argument."
        ;;
    esac
  done
}

save_restore ()
{
  local action="$1"
  local options=("$1" -exist -file)
  save_restore_message=""
  if "${IPSET}" "${options[@]}" "${IPSET_CONF}"; then
    if [[ "$1" == "restore" ]]; then
      save_restore_message="${save_restore_message}${TAB}${OK_LOGO} restore tmp sets from ipset.conf.\n"
      for set in "${SETS[@]}"; do
        if "${IPSET}" swap "${set}_tmp" "${set}"; then
          save_restore_message="${save_restore_message}${TAB}${OK_LOGO} swap ${set} ${set}_tmp.\n"
        fi
        if "${IPSET}" destroy "${set}_tmp"; then
          save_restore_message="${save_restore_message}${TAB}${OK_LOGO} destroy ${set}_tmp.\n"
        fi
      done
    fi
    if [[ "$1" == "save" ]]; then
      save_restore_message="${save_restore_message}${TAB}${OK_LOGO} save IP sets to ipset.conf.\n"
      if sed -i 's/\(whitelist\|blacklist\)/\1_tmp/g' "${IPSET_CONF}"; then
        save_restore_message="${save_restore_message}${TAB}${OK_LOGO} substitute set by tmp set in ipset.conf.\n"
      fi
    fi
  else
    screen_error "ipset ${action} failed."
  fi
}

usage ()
{
  CMD=$(basename "$0")
  cat <<-EOL

NAME
        manage_ipset

SYNOPSIS
        ${CMD} [OPTIONS]

DESCRIPTION
        Use common ipset commands.

OPTIONS
        -h        display this help
        -c        display colors
        -e        display emoticons
        -t [0-8]  choose tabulation space
                  default: 2

EXAMPLE
        sudo manage_ipset -cet 4
        EOL
}

screen_add_del ()
{
  local action_ad="$1"
  screen_number="$2"
  clear
  menu_title "${screen_number}" && echo -e "${title_message}"
  echo -e "${TAB}Choose a working set.\n"
  for i in "${!SETS[@]}"
  do
    echo -e "${TAB}${BGREEN}${SETS[$i]:0:1}${NC} ... ${SETS[$i]}"
  done | sort -n -k1
  echo
  while true; do
    read -rp "Type your choice, c to continue, q to quit: " response
    [[ "${response}" == "b" ]] && set_ad="blacklist" && break
    [[ "${response}" == "w" ]] && set_ad="whitelist" && break
    [[ "${response}" == "c" ]] && screen_main
    [[ "${response}" == "q" ]] && exit 0
    [[ "${response}" != [bwc] ]] && screen_add_del "${action_ad}" "${screen_number}"
  done
  clear
  echo -e "${title_message}"
  echo -e "${TAB}Choose IP address/range separated by spaces.\n${TAB}Working set: ${set_ad}.\n${TAB}"
  read -rp "Type your choice: " response
  add_del "${set_ad}" && echo -e "\n${add_del_message}"
  read -rp 'Type any key to continue, r to renew, q to quit: ' response
  [[ "${response}" == "r" ]] && screen_add_del "${action_ad}" "${screen_number}"
  [[ "${response}" == "q" ]] && exit 0
  [[ "${response}" != [cqr] ]] && screen_main
}

screen_error ()
{
  local error_message="$1"
  echo -e "${RED}Error: $error_message${NC}" >&2
  exit 1
}

screen_list ()
{
  screen_number="$1"
  clear
  "${IPSET}" list | grep -v "Type\|Revision\|Header\|Size\|References" \
  | less --mouse -P "Type q to quit, space to forward page, mouse scrolling is enable."
}

screen_help ()
{
  screen_number="$1"
  while true; do
    clear
    menu_title "${screen_number}" && echo -e "${title_message}"
    echo -e "${GLOBAL_COMMENT}${OK_COMMENT}${WARNING_COMMENT}${ERROR_COMMENT}\n"
    read -rp "Type c to continue, q to quit: " response
    [[ "${response}" == "c" ]] && screen_main
    [[ "${response}" == "q" ]] && exit 0
  done
}

screen_main ()
{
  while true; do
    clear
    menu_header && echo -e "${header_message}"
    menu_main && echo -e "${main_message}" | sort
    echo
    read -rp "Type your choice: " response
    [[ "${response}" == [1l] ]] && screen_list "1l"
    [[ "${response}" == [2a] ]] && screen_add_del "add" "2a"
    [[ "${response}" == [3d] ]] && screen_add_del "del" "3d"
    [[ "${response}" == [4s] ]] && screen_save_restore "save" "4s"
    [[ "${response}" == [5r] ]] && screen_save_restore "restore" "5r"
    [[ "${response}" == [6h] ]] && screen_help "6h"
    [[ "${response}" == [7q] ]] && exit 0
  done
}

screen_save_restore ()
{
  local sr_action="$1"
  screen_number="$2"
  clear
  menu_title "${screen_number}" && echo -e "${title_message}"
  check_conf_file_syntax && echo -e "${syntax_message}"
  if [[ "${sr_action}" == "save" || ("${sr_action}" == "restore" && "${syntax_error}" == 0) ]]; then
    echo -e "\n${YELLOW}Overwrite ${SR_ACTION[${sr_action}]} ? Please confirm ${sr_action}.${NC}"
    read -rp "Type yes to ${sr_action}, c to continue, q to quit: " response
    [[ "${response}" == "yes" ]] && save_restore "${sr_action}" && echo -e "\n${save_restore_message}"
    [[ "${response}" == "c" ]] && screen_main
    [[ "${response}" == "q" ]] && exit 0
    [[ ! "${response}" =~ (yes|[cq])  ]] && screen_save_restore "${sr_action}" "${screen_number}"
  fi
  if [[ "${sr_action}" == "restore" && "${syntax_error}" -gt 0 ]]; then
    check_number "\n${RED}Unable to restore ipset.conf file containing ${syntax_error} error" "${syntax_error}" && \
      echo -e "${number_message}"
  fi
  read -rp 'Type any key to continue, q to quit: ' response
  [[ "${response}" == "q" ]] && exit 0
  [[ "${response}" != "q" ]] && screen_main
}

# Main
parse_options "$@"
before_main
screen_main

# EOF