Script-Template

Da ich öfter Bash-Scripte schreibe und immer wieder grundlegende Strukturen benötige, habe ich mir ein Template erstellt, in dem die meisten Funktionen und Strukturen bereits vorhanden sind. Je nach Anforderung lässt es sich leicht anpassen.

Bisher enthaltene Funktionen:

  • Automatischer Error-Handler mit ausführlichen Informationen
  • Root-Check mit Unterscheidung zwischen sudo und “echtem” lokalen root-Login
  • Konfigurierbare Prüfung auf benötigte Kommandos/Tools
  • Einheitlicher Message-Handler mit automatischem Logging, Zeitstempeln und farbigen Tags
  • Automatischer Start von tmux
  • Parameter-Parser mit Kurz- und Langform sowie beliebig vielen unbenannten Parametern am Ende des Aufrufs
  • Funktion zur Darstellung zentrierter Texte
  • Header mit Logo, Kurzbeschreibung, Version und Slogan des Scripts
  • Integrierte Bedienungsanleitung
  • Beispielfunktion test_me als Einstieg

Dieses Script sowie alle anderen Bash-Snippets, die ich auf meiner Webseite veröffentliche, orientieren sich so gut es geht an Googles Shell Style Guide (unter anderem geprüft mittels shfmt und einer angepassten .editorconfig) und produzieren keine Fehler bei ShellCheck.

Bitte denke daran, dass auch dieses Script nach CC-BY-SA 4.0 lizensiert ist. Du darfst es frei verwenden, anpassen, umschreiben, sogar kommerziell weiterverwenden, aber egal was Du damit tust, die Lizenz muss dieselbe bleiben und Du musst mich in geeigneter Form als Urheber des ursprünglichen Scriptes nennen. Eine komplette Erklärung der Lizenzbedingungen ist verlinkt. In der Regel sollte es ausreichen, die “Written by”- und “License”-Zeilen im Header stehen zu lassen.

Sourcecode

#!/usr/bin/env bash

# Example script v1.0
# Written by Folker "ph0lk3r" Schmidt (info@ichbinein.org) in 2025
# Last updated on 02/2025 by ph0lk3r
# License: CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/legalcode)
#
# This is a short description of the script
#
# Exit codes:
# 0 -> everything is okay
# 1 -> general error
# 2 -> missing privileges
# 3 -> privileged but run as "root" instead of sudo
# 4 -> missing tool
# 5 -> wrong parameter
# 6 -> file not found
# 7 -> directory not found
# 8 -> logpath not writeable

# GLOBALS (constants)
readonly VERSION='1.0'
readonly SLOGAN='Hack the planet!'
readonly DESCRIPTION='A simple example script'
START_DATE_TIME="$(date +'%Y-%m-%d_%H-%M-%S')"
readonly START_DATE_TIME
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
RUNNING_USER="$(logname)"
readonly RUNNING_USER

# REQUIREMENTS
readonly REQUIRED=(tmux lsof nano vim getopt logname)

# GLOBALS (variables)
LOGPATH="/tmp/${SCRIPT_NAME}_${START_DATE_TIME}.log"
LOGPATH_EXISTS=0
PARAMETERS=()
EXAMPLE_FILES=()
EXAMPLE_DIRS=()
COLOUR_GREEN=''
COLOUR_RED=''
COLOUR_CYAN=''
COLOUR_YELLOW=''
COLOUR_GREY=''
COLOUR_NONE=''
COLOUR_BLINK=''
COLOUR_BLINK_RED=''

###########################################################
# Caller for the following error handler function.
# # Globals:
#   None
# Arguments:
#   None
###########################################################
set_options() {
  set -e
  set -u
  set -o pipefail
  set -o errtrace
  trap 'catch $? $LINENO $BASH_LINENO ${FUNCNAME[0]} "${PIPESTATUS[*]}"' ERR
}

###########################################################
# General error handler.
# Displays some information about the error and where it
# occurred exactly.
# Globals:
#   None
# Arguments:
#   $?
#   $LINENO
#   $BASH_LINENO
#   $BASH_COMMAND
#   ${FUNCNAME[0]}
# Output:
#   Detailed information about the error
# Return values:
#   1: General error
###########################################################
catch() {
  local -i error_code
  error_code="$1"
  local -i error_line
  error_line="$2"
  local -i calling_line
  calling_line="$3"
  local error_function
  error_function="$4"
  local -a pipe_status
  IFS=' ' read -r -a pipe_status <<< "$5"
  local line_content
  line_content="$(sed "${error_line}!d" "$0" | xargs)"
  message "c" "Error ${error_code} occurred on line ${error_line}"
  message "c" "In function ${error_function}"
  message "c" "The function was called at line ${calling_line}"
  message "c" "The failing line was:\t\t${line_content}"
  if [[ ${#pipe_status[@]} -gt 1 ]]; then
    local -a pipe_commands
    readarray -d '|' -t pipe_commands <<< "${line_content}"
    for command in "${!pipe_commands[@]}"; do
      if [[ ${pipe_status[${command}]} -gt 0 ]]; then
        local -i field
        field=$((command + 1))
        message "c" "The failing subcommand was:\t$(echo "${line_content}" \
          | cut -d "|" -f ${field} \
          | xargs)"
        break
      fi
    done
  fi
  chown_logfile
  exit 1
}

###########################################################
# Check if the script has been called with effective root
# permissions (aka sudo).
# Globals:
#   None
# Arguments:
#   None
# Return values:
#   2: Script not started with elevated access
#   3: Script started as root
###########################################################
check_root() {
  if [[ "${EUID}" -ne 0 ]]; then
    message "e" "Please run this script with sudo: \tsudo $0"
    exit 2
  fi
  if [[ "${RUNNING_USER}" == "root" ]]; then
    message "e" "Please DON'T run this script as root!"
    message "e" "Use your sudo-enabled user account."
    exit 3
  fi
}

#######################################
# This function simply displays a usage text with all required and optional
# parameters.
# Globals:
#   None
# Arguments:
#   None
# Outputs:
#   Usage information
#######################################
display_usage() {
  echo "Usage: $0 [-d|--directory] [-f|--file] [-h|--help] [strings]"
  echo ""
  echo -e "\t-f|--file\t\t(optional) \
Provide a file to the script, can be used multiple times"
  echo -e "\t-d|--directory\t\t(optional) \
Provide a directory to the script, can be used multiple times"
  echo -e "\t-h|--help\t\t(optional) Displays this message"
  echo -e "\tstring\t\t\t(optional) \
  Just a string provided to the script. Must be the last parameter"
  echo ""
}

#######################################
# This function checks if the needed tools are being installed and accessible
# and if a tmux session is active. Displays an error message if needed tools
# are missing and starts a new tmux session if there is none active.
# Globals:
#   REQUIRED R
# Arguments:
#   None
# Return values:
#   4: Required command not found
#######################################
check_requirements() {
  local -i fail_counter
  fail_counter=0
  for tool in "${REQUIRED[@]}"; do
    if ! command -v "${tool}" &> /dev/null; then
      message "c" "Not found: ${tool}"
      fail_counter=$((fail_counter + 1))
    fi
  done
  if [[ ${fail_counter} -gt 0 ]]; then
    message "c" "${fail_counter} tools could not be found."
    exit 4
  fi
}

#######################################
# This function checks if the configured logpath can be written. It tries to create
# the directory and if it fails, it tries to create a local tmp directory to
# write into.
# Globals:
#   LOGPATH RW
#   START_DATE_TIME R
# Arguments:
#   None
# Return values:
#   8: Log path could not be created
#######################################
check_logpath() {
  local log_dir
  log_dir="$(realpath "$(dirname "${LOGPATH}")")"
  if [[ ! -d "${log_dir}" ]]; then
    message "w" "Log directory ${log_dir} does not exist. Trying to create..."
    if ! mkdir -p "${log_dir}"; then
      message "w" "Log directory ${log_dir} could not be created."
      message "w" "Trying to create an alternative directory..."
      if ! mkdir -p "./tmp"; then
        message "c" "Could not create directory $(realpath "./tmp")."
        exit 8
      else
        LOGPATH="$(realpath "./")/${SCRIPT_NAME}_${START_DATE_TIME}.log"
      fi
    fi
  fi
  LOGPATH_EXISTS=1
}

#######################################
# This function checks the script runs inside a tmux session and restarts it
# inside tmux if necessary.
# Globals:
#   START_DATE_TIME R
#   RUNNING_USER R
# Arguments:
#   $@
#######################################
check_tmux() {
  if [[ ! "$TERM" =~ screen.*|tmux.* ]]; then
    echo -e "tmux environment not found. Starting a new instance in tmux."
    sleep 1
    tmux -2 new-session -d -s "${RUNNING_USER}_${START_DATE_TIME}"
    sleep 1
    tmux -2 send "$0 $*" ENTER
    tmux -2 attach -t "${RUNNING_USER}_${START_DATE_TIME}"
    exit 0
  fi
}

#######################################
# This function handles all command line parameters that the script received.
# It receives a list of all given parameters, sets variables according to their
# presence and displays an error message when parameterss are wrong or missing.
# Globals:
#   None
# Arguments:
#   $@
# Return values:
#   5: Wrong argument
#   6: File not found
#   7: Directory not found
#######################################
get_params() {
  if ! ARGS="$(getopt -o "f:d:h" --long "file:directory:help" -- "$@")"; then
    echo ""
    display_usage
    exit 5
  fi
  eval set -- "${ARGS}"
  while [[ -n ${1+x} ]]; do
    case "$1" in
      -f | --file)
        if [ -z "$2" ] || [ ! -f "$2" ]; then
          message "e" "-f/--file option requires a valid file path."
          exit 6
        fi
        EXAMPLE_FILES+=("$2")
        shift 2
        ;;
      -d | --directory)
        if [ -z "$2" ] || [ ! -d "$2" ]; then
          message "e" "-d/--directory option requires a valid directory path."
          exit 7
        fi
        EXAMPLE_DIRS+=("$2")
        shift 2
        ;;
      -h | --help)
        display_usage
        exit 0
        ;;
      --)
        shift
        ;;
      *)
        if [[ -z ${1+x} ]]; then
          break
        else
          PARAMETERS+=("$1")
        fi
        shift
        ;;
    esac
  done
}

#######################################
# If the terminal supports colored output, the colors are set by this function.
# Globals:
#   COLOUR_GREEN W
#   COLOUR_RED W
#   COLOUR_CYAN W
#   COLOUR_YELLOW W
#   COLOUR_GREY W
#   COLOUR_NONE W
#   COLOUR_BLINK W
#   COLOUR_BLINK_RED W
# Arguments:
#   None
#######################################
set_colours() {
  if [[ "$(tput colors)" -ne -1 ]]; then
    COLOUR_GREEN='\033[0;32m'
    COLOUR_RED='\033[0;31m'
    COLOUR_CYAN='\033[0;36m'
    COLOUR_YELLOW='\033[0;93m'
    COLOUR_GREY='\033[0;90m'
    COLOUR_NONE='\033[0;0m'
    COLOUR_BLINK='\033[0;5m'
    COLOUR_BLINK_RED='\033[31;5m'
  fi
}

#######################################
# This function centers a text. It has a static width of 50 columns configured
# because it is only used by the display_header function
# Globals:
#   None
# Arguments:
#   Text to be centered
# Outputs:
#   Centered text
#######################################
display_center() {
  local columns
  local centerline
  columns="52"
  centerline="$1"
  printf "%*s\n" $(((${#centerline} + columns) / 2)) "${centerline}"
}

#######################################
# Displays a description, version and a slogan.
# Globals:
#   None
# Arguments:
#   None
# Outputs:
#   A header logo and script information
#######################################
display_header() {
  echo ''
  echo '░▒▓█▓▒░      ░▒▓██████▓▒░ ░▒▓██████▓▒░ ░▒▓██████▓▒░  '
  echo '░▒▓█▓▒░     ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ '
  echo '░▒▓█▓▒░     ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░      ░▒▓█▓▒░░▒▓█▓▒░ '
  echo '░▒▓█▓▒░     ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒▒▓███▓▒░▒▓█▓▒░░▒▓█▓▒░ '
  echo '░▒▓█▓▒░     ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ '
  echo '░▒▓█▓▒░     ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ '
  echo '░▒▓████████▓▒░▒▓██████▓▒░ ░▒▓██████▓▒░ ░▒▓██████▓▒░  '
  echo
  display_center "${DESCRIPTION}"
  display_center "v${VERSION} \"${SLOGAN}\""
  echo ''
}

#######################################
# This function is the main message parser used for text output.
# It handles the prepended timecode, colours and the following message types:
# - okay: Best used after a "waiting"" message for marking successful execution
# - wait: Displays a waiting status which will be replaced by the next message
# - warning: Displays a warning tag for non-functioning warnings
# - error: Displays an error tag for failed commands
# - critical: Displays a message signalling immediate danger
# - debug: Displays debug messages when the debug parameter is set
# - no message type: Displays normal output as "INFO"
# Colours are handled automatically and should not be set within the messages
# manually.
# This function is also responsible to write all messages to the log file
# "${LOGPATH}"
# Globals:
#   LOGPATH R
# Arguments:
#   message_type: Type as of the list above; optional
#   message_text: First parameter, containing the text to be displayed
# Outputs:
#   Formatted text
#######################################
message() {
  local message_type
  local message_text
  local message_tag
  local message_suffix
  local colour_prefix
  local colour_suffix
  local timecode_prefix
  local -i is_error
  message_text=""
  message_type=""
  message_tag=""
  colour_prefix=""
  colour_suffix=""
  message_suffix="\n"
  timecode_prefix="$(date +'%F %T')\t"
  is_error=0
  set_colours
  if [[ "$#" -ne 1 ]]; then
    message_text="$2"
    message_type="$1"
    case "${message_type}" in
      e | err | error)
        message_tag="FAIL"
        colour_prefix="${COLOUR_RED}"
        colour_suffix="${COLOUR_NONE}"
        is_error=1
        ;;
      w | warn | warning)
        message_tag="WARN"
        colour_prefix="${COLOUR_YELLOW}"
        colour_suffix="${COLOUR_NONE}"
        is_error=1
        ;;
      c | crit | critical)
        message_tag="CRIT"
        colour_prefix="${COLOUR_BLINK_RED}"
        colour_suffix="${COLOUR_NONE}"
        is_error=1
        ;;
      wait)
        message_tag="WAIT"
        colour_prefix="${COLOUR_BLINK}"
        colour_suffix="${COLOUR_NONE}"
        message_suffix="\033[0K\r"
        ;;
      o | ok | okay)
        message_tag="OKAY"
        colour_prefix="${COLOUR_GREEN}"
        colour_suffix="${COLOUR_NONE}"
        ;;
      d | debg | debug)
        message_tag="DEBG"
        colour_prefix="${COLOUR_GREY}"
        colour_suffix="${COLOUR_NONE}"
        ;;
      *)
        message_tag="INFO"
        colour_prefix="${COLOUR_NONE}"
        colour_suffix="${COLOUR_NONE}"
        ;;
    esac
  else
    message_tag="XXXX"
    colour_prefix="${COLOUR_CYAN}"
    colour_suffix="${COLOUR_NONE}"
    message_text="$1"
  fi
  if [[ ${is_error} -eq 1 ]]; then
    echo -ne "\033[2K${timecode_prefix}[${colour_prefix}${message_tag}\
${colour_suffix}]\t${message_text}${message_suffix}" >&2
  else
    echo -ne "\033[2K${timecode_prefix}[${colour_prefix}${message_tag}\
${colour_suffix}]\t${message_text}${message_suffix}"
  fi
  if [[ ${LOGPATH_EXISTS} -eq 1 ]]; then
    echo -e "${timecode_prefix}[${message_tag}]\t${message_text}" >> "${LOGPATH}"
  fi
}

#######################################
# This function just displays some messages
# Globals:
#   LOGPATH R
#   RUNNING_USER R
# Arguments:
#   None
#######################################
chown_logfile() {
  chmod 660 "${LOGPATH}"
  chown "${RUNNING_USER}" "${LOGPATH}"
}

#######################################
# This function just displays some messages
# Globals:
#   EXAMPLE_FILES R
#   EXAMPLE_DIRS R
#   PARAMETERS R
#   RUNNING_USER R
# Arguments:
#   None
#######################################
test_me() {
  display_header
  message "Try this script with $0 -d /tmp -d /home \
-f /home/${RUNNING_USER}/.bashrc -f /etc/passwd this is a test"
  if [[ "${#EXAMPLE_FILES[@]}" -gt 0 ]]; then
    message "o" "Example files (${#EXAMPLE_FILES[@]}): ${EXAMPLE_FILES[*]}"
  fi
  if [[ "${#EXAMPLE_DIRS[@]}" -gt 0 ]]; then
    message "o" "Example dirs (${#EXAMPLE_DIRS[@]}): ${EXAMPLE_DIRS[*]}"
  fi
  if [[ "${#PARAMETERS[@]}" -gt 0 ]]; then
    message "w" "Additional parameters (${#PARAMETERS[@]}): ${PARAMETERS[*]}"
  fi
  message "i" "Running as ${RUNNING_USER}"
  message "wait" "Is that really all folks?"
  sleep 2
  message "d" "That's all folks!"
  message "Log file written to ${LOGPATH}"
  message "e" "The next line will provoke an error in two seconds!"
  sleep 2 | true | false | /bin/true | /bin/false
}

#######################################
# Finally, the main function. It orchestrates all the other functions and makes
# sure that each of them gets all the parameters it needs and that they are
# called in the correct order.
# Globals:
#   None
# Arguments:
#   $@
#######################################
main() {
  set_options
  get_params "$@"
  check_logpath
  check_requirements
  check_root
  check_tmux "$@"
  
  test_me "$@"
  
  chown_logfile
}

main "$@"