Shell Scripting¶
Help Message¶
- HN: Please don’t print –-help to stderr in your CLI tools
--helpgoes to stdout--invalid-optionthat prints help message goes to stderr, and returns non-zero exit code. Best would be theEX_USAGE (64)
#!/bin/sh
###
### my-script — does one thing well
###
### Usage:
### my-script <input> <output>
###
### Options:
### <input> Input file to read.
### <output> Output file to write. Use '-' for stdout.
### -h Show this message.
help() {
sed -n 's/^### \?//p' "$0"
}
if [ $# -eq 0 ] || [ "$1" = "-h" ]; then
help
exit 1
fi
echo Hello World
Logging and Debugging¶
Shell Scripting: Expert Recipes for Linux, Bash, and More: logger
https://medium.com/picus-security-engineering/structured-logging-in-shell-scripting-dd657970cd5d
Structured logging with jq:
__log(){
jq \
--monochrome-output \
--compact-output \
--raw-output \
--arg timestamp "$(date "+%Y%m%dT%H%M%S")" \
--arg log_level "${1}" \
--arg message "${2}" \
'.timestamp=$timestamp|.log_level=$log_level|.message=$message' \
<<<'{}' >&2
}
__log "INFO" "Hello, World"
Print with a colon:
if [[ -n ${COLODEBUG:-} && ${-} != *x* ]]; then
:() {
[[ ${1:--} != ::* ]] && return 0
printf '%s\n' "${*}" >&2
}
fi
: :: note this line
: ::NOTICE::
# This is an example comment
: This is a colon comment
: :: This is a more verbose colon comment
# Example usage:
# COLODEBUG=1 ./myscript.sh
Write logs to syslog/journald:
logger -t checkfs -s -p user.err "Filesystem $filesystem is at $usage"
# or, with long options:
logger --tag checkfs --stderr --priority user.err "Filesystem $filesystem is at $usage"
Besides traps(handlers) for signals, bash have 4 special traps:
EXITto run on exit from the shell.RETURNto run each time a function or a sources script finishes.ERRto run each time command failure would cause the shell to exit ifset -eis used.DEBUGto execute before every command.
The last one allows to create a simple debugger inside a bash script:
function _trap_DEBUG() {
echo "# $BASH_COMMAND";
while read -r -e -p "debug> " _command; do
if [ -n "$_command" ]; then
eval "$_command";
else
break;
fi;
done
}
trap '_trap_DEBUG' DEBUG
You can change the set -x output format:
# Enable verbose logging of the bash script
PS4='+ ${BASH_SOURCE:-}:${FUNCNAME[0]:-}:L${LINENO:-}: '
set -x
Alternatives:
Locking¶
Run only one instance of the script:
# inside the script
[ "$(pidof -x $(basename $0))" != $$ ] && exit
# or, controlling outside the script:
# -w 0: wait for the lock release for 0s (exit immediately)
flock \
-w 0 /tmp/test.lock \
-c 'echo "sleeping" && sleep 60' ||
echo "cannot be executed an instance already runs"
Functions¶
In bash/zsh keyword function intruduced for historical reasons. Use more portable f() ...
Local variables are dinamically scoped (seen by functions down the call stack):
f1() { local a; a=1 f3; }
f2() { local a; a=2 f3; }
f3() { echo $a; }
f1 # prints 1
f2 # prints 2
Shebangs¶
Python¶
Pick python version before running:
From dotbot:
#!/usr/bin/env sh
# This is a valid shell script and also a valid Python script. When this file
# is executed as a shell script, it finds a python binary and executes this
# file as a Python script, passing along all of the command line arguments.
# When this file is executed as a Python script, it loads and runs Dotbot. This
# is useful because we don't know the name of the python binary.
''':' # begin python string; this line is interpreted by the shell as `:`
command -v python >/dev/null 2>&1 && exec python "$0" "$@"
command -v python3 >/dev/null 2>&1 && exec python3 "$0" "$@"
command -v python2 >/dev/null 2>&1 && exec python2 "$0" "$@"
>&2 echo "error: cannot find python"
exit 1
'''
# python code
import sys, os
...
Version from SO:
#!/bin/bash
'''':
for interpreter in python3 python2 python
do
which $interpreter >/dev/null 2>&1 && exec $interpreter "$0" "$@"
done
echo "$0: No python could be found" >&2
exit 1
# '''
import sys
print(sys.version)
nix-shell¶
My shebang for running things in nix-shell and failing back to bash if no nix-shell is available:
#!/usr/bin/env sh
MYSCRIPT="$(mktemp)"
trap 'rm -f -- "${MYSCRIPT}"' EXIT
to_print=
while IFS= read -r line; do
[ "${line}" = '#!/usr/bin/env nix-shell' ] && to_print=yes
[ -n "${to_print}" ] && printf '%s\n' "${line}"
done <"$0" >"${MYSCRIPT}"
# or, use sed instead of the above:
# sed -n '/^#!\/usr\/bin\/env nix-shell$/,$p' "$0" >"${MYSCRIPT}"
command -v nix-shell >/dev/null 2>&1 && exec nix-shell "${MYSCRIPT}" "$@"
command -v bash >/dev/null 2>&1 && exec bash "${MYSCRIPT}" "$@"
>&2 echo "error: cannot find nix-shell or bash"
exit 1
#!/usr/bin/env nix-shell
#!nix-shell -i bash --pure
#!nix-shell -p bash cacert curl jq python3Packages.xmljson
#!nix-shell -I nixpkgs=https://github.com/NixOS/nixpkgs/archive/2a601aafdc5605a5133a2ca506a34a3a73377247.tar.gz
curl https://github.com/NixOS/nixpkgs/releases.atom | xml2json | jq .
Executable sqlite3¶
Executable sqlite3 files:
# Create database
sqlite3 db.sqlite3 <<EOF
create table echo
(echo none primary key)
without rowid;
insert into echo values ("
ls /
")
EOF
# Inspect it
sqlite3 db.sqlite3 '.schema'
xxd db.sqlite3 |grep -v ': [0 ]* '
busybox ash db.sqlite3
Executable Dockerfile¶
- adtac/Dockerfile
- The
env -S / --split-stringis a rather recent GNU Coreutils addition
- The
need to add
|tail -n1to get only required image hash
#!/usr/bin/env -S bash -c "docker run -p 8080:8080 -it --rm \$(docker build --progress plain -f \$0 . 2>&1 |tee /dev/stderr |grep -oP 'sha256:[0-9a-f]*' |tail -n1)"
# syntax = docker/dockerfile:1.4.0
FROM node:20
...
Executable Makefile¶
- How to “make” a shell script
mind that Makefile requires tabs and not spaces
#!/usr/bin/env bash
dummy=; define () { true; }
define dummy
echo "Hello from shell; PATH is ${PATH}"
return 0 2>/dev/null || exit 0
endef
.PHONY: say-hello
say-hello:
@echo "Hello from make; makevar PATH is $(PATH), envvar PATH is $${PATH}"
Testable documentation with curl snippets¶
# Executable document (via `bash README.md`):
curl() { command curl -s --fail --retry 2 --retry-connrefused --connect-timeout 180 --max-time 360 -o/dev/null -w"%{http_code} %{url_effective}\n" "$@"; }
jq() { grep --color=auto -E '^[^2].*|$'; }
source <(grep -E "^(curl|\-d|'http)" "$0" |sed "s@https.*example\.com@${1:-https://example.com}@g")
exit
The block above enables calling:
* `bash README.md`
* `bash README.md http://localhost:8000`
## An example snippet
```shell
curl -sSXGET -H 'Content-Type:application/json' -H 'Authorization: Bearer mytoken' \
'https://example.com/myprefix' |jq
```
Other¶
Examples:
delete.sh:
#!/bin/rmdiary.sh:
#!/usr/bin/nano +99999disable.sh:
#!/bin/chmod a-xempties.sh:
#!/bin/cp /dev/nullgrow_a_tree_from_roots.sh:
#!/bin/sudonotebook.sh:
#!/bin/tee -aquine.sh:
#!/bin/catselfls.sh:
#!/bin/ls -l
Traps¶
Snippets¶
Removing temporary files:
trap 'rm -f "$TMPFILE"' EXIT
TMPFILE=$(mktemp) || exit 1
scratch=$(mktemp -d -t tmp.XXXXXXXXXX)
finish() {
rm -rf "$scratch"
}
trap finish EXIT
Bringing service back up after maintanence:
finish() {
# re-start service
sudo service mongdb start
}
trap finish EXIT
# Stop the mongod instance
sudo service mongdb stop
# (If mongod is configured to fork, e.g. as part of a replica set, you
# may instead need to do "sudo killall --wait /usr/bin/mongod".)
Debugging a bash script:
failure() {
echo "Failed at ${1}: ${2}"
}
trap 'failure "$LINENO" "BASH_COMMAND"' ERR
Shell Specific¶
POSIX Shell:
Signal terminations are not caught by EXIT. It only catches normal exits. Unfortunately, the EXIT condition is not well-defined by POSIX, so it’s left to interpretation.
Dash calls trap EXIT only on normal exit.
trap 'eval $(ssh-agent -k)' EXIT INT ABRT KILL TERM
# or: trap 'eval $(ssh-agent -k)' EXIT SIGINT SIGABRT SIGKILL SIGTERM
Bash:
Unlike dash, bash calls trap EXIT for all signals.
Also, bash has ERR to catch when a script fails (non-POSIX feature).
Zsh:
In Zsh, EXIT and ERR behaves similar to bash.
{
echo lol
} always {
# Ensure all temporary files are cleaned up.
nohup rm -rf /app/tmp &
}
Self Extracting Scripts¶
Approaches for embedding data:
- By techinque of embedding
- At the end of the script
doesn’t work with
curl |bash
- Using a heredoc
doesn’t work with binary
hard to automatically substitute for text with newlines
- Using a string variable
doesn’t work with binary
- By data type
Text
- Binary
raw
gzipped
base64-encoded (text)
gzipped and base64-encoded (text)
You can add data to the end of the script.
Single file static web page¶
Served by netcat.
Mind that template static_web_template.sh should end with newline:
#!/env/bin/env bash
trap 'rm -f "$TMPFILE"' EXIT
TMPFILE=$(mktemp) || exit 1
sed -e '1,/^exit # exit before data section$/d' "${0}" > "$TMPFILE"
while :; do
{
echo -ne "HTTP/1.0 200 OK\r\nContent-Length: $(wc -c <"$TMPFILE")\r\n\r\n"
cat "$TMPFILE"
} | nc -l -p 8000 -q 1
done
exit # exit before data section
Creating the target script:
{ cat static_web_template.sh
curl -sL example.com
} > static_web.sh
Adding binary data to the script¶
Template file for opening an image in sxiv:
#!/env/bin/env bash
trap 'rm -f "$TMPFILE"' EXIT
TMPFILE=$(mktemp) || exit 1
sed -e '1,/^exit # exit before data section$/d' "${0}" > "$TMPFILE"
sxiv "$TMPFILE"
exit # exit before data section
Creating the script:
{ cat img_viewer_template.sh
curl -sSL https://upload.wikimedia.org/wikipedia/en/a/a9/Example.jpg
} > img_viewer.sh
Archive in a string variable¶
Script template (unpacks icons collection and lets you view them in browser at http://localhost:8000):
#!/env/bin/env bash
trap 'rm -rf "$TMPDIR"' EXIT
TMPDIR=$(mktemp -d) || exit 1
ARCHIVE_BASE64=''
printf "%s" "${ARCHIVE_BASE64}" |base64 -d |
tar -xz -C "${TMPDIR}" --strip-components=2 simple-icons-9.9.0/icons
python3 -m http.server --directory "${TMPDIR}"
Creating the script:
{ sed "/^ARCHIVE_BASE64=''\$/Q" icon_viewer_template.sh
printf "ARCHIVE_BASE64='"
curl -sSL https://github.com/simple-icons/simple-icons/archive/refs/tags/9.9.0.tar.gz |base64 --wrap=0
printf "'\n"
sed -e "1,/^ARCHIVE_BASE64='.*'\$/d" icon_viewer_template.sh
} > icon_viewer.sh
Sqlite database embedded in a mutable script¶
#!/env/bin/env bash
set -euo pipefail
trap 'rm -rf "${TMP_ARCHIVE}" "${TMP_SCRIPT}"' EXIT
TMP_ARCHIVE=$(mktemp) || exit 1
TMP_SCRIPT=$(mktemp) || exit 1
ARCHIVE_BASE64=''
offload_archive() {
printf "%s" "${ARCHIVE_BASE64}" |base64 -d >"${TMP_ARCHIVE}"
}
init_archive_if_empty() {
if [[ ! -s "${TMP_ARCHIVE}" ]]; then
sqlite3 "${TMP_ARCHIVE}" "create table app (id INTEGER PRIMARY KEY, event TEXT);"
fi
}
load_archive_and_exit() {
{
sed "/^ARCHIVE_BASE64='[A-Za-z0-9+\/=]*'\$/Q" "${0}"
printf "ARCHIVE_BASE64='"
base64 --wrap=0 "${TMP_ARCHIVE}"
printf "'\n"
sed -e "1,/^ARCHIVE_BASE64='[A-Za-z0-9+\/=]*'\$/d" "${0}"
} > "${TMP_SCRIPT}"
cp -f "${TMP_SCRIPT}" "${0}"
exit
}
business_logic() {
sqlite3 "${TMP_ARCHIVE}" "insert into app (event) values ('${1} $(date --utc --iso-8601=seconds)');"
sqlite3 "${TMP_ARCHIVE}" "select * from app;"
}
main() {
offload_archive
init_archive_if_empty
business_logic "${1}"
load_archive_and_exit
}
main "Your mom at"
Argument Parsing¶
# More safety, by turning some bugs into errors.
# Without `errexit` you don’t need ! and can replace
# ${PIPESTATUS[0]} with a simple $?, but I prefer safety.
set -o errexit -o pipefail -o noclobber -o nounset
# -allow a command to fail with !’s side effect on errexit
# -use return value from ${PIPESTATUS[0]}, because ! hosed $?
! getopt --test > /dev/null
if [[ ${PIPESTATUS[0]} -ne 4 ]]; then
echo 'I’m sorry, `getopt --test` failed in this environment.'
exit 1
fi
# option --output/-o requires 1 argument
LONGOPTS=debug,force,output:,verbose
OPTIONS=dfo:v
# -regarding ! and PIPESTATUS see above
# -temporarily store output to be able to check for errors
# -activate quoting/enhanced mode (e.g. by writing out “--options”)
# -pass arguments only via -- "$@" to separate them correctly
! PARSED=$(getopt --options=$OPTIONS --longoptions=$LONGOPTS --name "$0" -- "$@")
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
# e.g. return value is 1
# then getopt has complained about wrong arguments to stdout
exit 2
fi
# read getopt’s output this way to handle the quoting right:
eval set -- "$PARSED"
d=n f=n v=n outFile=-
# now enjoy the options in order and nicely split until we see --
while true; do
case "$1" in
-d|--debug)
d=y
shift
;;
-f|--force)
f=y
shift
;;
-v|--verbose)
v=y
shift
;;
-o|--output)
outFile="$2"
shift 2
;;
--)
shift
break
;;
*)
echo "Programming error"
exit 3
;;
esac
done
# handle non-option arguments
if [[ $# -ne 1 ]]; then
echo "$0: A single input file is required."
exit 4
fi
echo "verbose: $v, force: $f, debug: $d, in: $1, out: $outFile"
ZSH¶
Completions¶
Alternate Forms¶
Expansion¶
man 1 zshexpn
Modifiers¶
LESS="$LESS+/^ *Modifiers$" man 1 zshexpn
${VAR:modifier}
${VAR:modifier1:modifier2}
VAR=/path/to/image.png
echo ${VAR:h} # --> /path/to
echo ${VAR:t} # --> image.png
echo ${VAR:r} # --> /path/to/image
echo ${VAR:e} # --> png
VAR=image.png
echo ${VAR:a} # --> /home/lain/image.png
CMD=nvim
echo ${CMD:c} # --> /usr/local/bin/nvim
STR="file1.txt file2.txt"
echo ${STR:s/txt/md} # --> file1.md file2.txt
echo ${STR:gs/txt/md} # --> file1.md file2.md
MYFILE=img/large/01.png
echo ${MYFILE:gs_/_._} # --> img.large.01.png
print -l **/*.jpg(:t:r)
VAR="Hello zshell 'world'"
echo ${VAR:q} # --> Hello\ zshell\ \'world\'
VAR="Hello\ zshell\ \'world\'"
echo ${VAR:q} # --> Hello zshell 'world'
VAR="UP down"
echo ${VAR:l} # --> up down
echo ${VAR:u} # --> UP DOWN
STR="0123456789"
echo ${STR:0:4} # --> 012
echo ${STR:0:-2} # --> 01234567
echo ${STR:2} # --> 23456789
Parameter Expansion¶
LESS="$LESS+/^ *Parameter Expansion Flags$" man 1 zshexpn
VAR="UP down"
echo ${(L)VAR} # --> up down
echo ${(C)VAR} # --> Up Down
echo ${(U)VAR} # --> UP DOWN
STR="foo
bar"
array=(${(f)STR}) # $array[1] == foo
array=(${(ps/\n/)STR}) # $array[2] == bar
string=${(F)array} # $string == $STR
string=${(pj/\n/)array}
Glob Qualifiers¶
LESS="$LESS+/^ *Glob Qualifiers$" man 1 zshexpn
print -l **/*(/) # show only directories
print -l **/*(.) # show only regular files
ls -l **/*(L0) # show empty files
ls -l **/*(Lk+3) # show files greater than 3 KB
print -l **/*(mh-1) # show files modified in the last hour
ls -l **/*(om[1,3]) # sort files from most to least recently modified and show the last 3
print -l **/*([1]) # 1'st match
ls *.^c(.) # all files excluding c files
ls -l *.(png|jpg|gif) # images only
ls *(*) # executables only
ls /etc/**/zsh # which directories contain 'zsh'?
ls **/*(/^F) # list empty directories
ls /etc/*(@) # symlinks only
ls **/*(-@) # list dangling symlinks
ls **/*(D.) # list files, including hidden ones
# . - regular files
# Lm-2 - files smaller than 2 MB
# mh-1 - files modified in the last hour
# om - recent first
# [1,3] - first 3 files
ls -l zsh_demo/**/*(.Lm-2mh-1om[1,3])
# show every continent that doesn't contain a country named malta
# e - estring (delimited with e.g. `:`)
# $REPLY - current file
# [[ ... ]] - conditional expression
print -l zsh_demo/*/*(e:'[[ ! -e $REPLY/malta ]]':)
# Return the parent folder of the first file
print -l zsh_demo/data/europe/poland/*.txt([1]:h)
my_file=(zsh_demo/data/europe/poland/*.txt([1]))
print -l $my_file(:h) # this is the syntax we saw before
print -l ${my_file:h} # I find this syntax more convenient
echo ${(s._.)file:t}
Pitfalls¶
String-to-int arbitrary code execution¶
https://yossarian.net/til/post/some-surprising-code-execution-sources-in-bash/
https://github.com/oils-for-unix/blog-code/blob/main/crazy-old-bug
https://lobste.rs/s/mla0ns/til_some_surprising_code_execution
#!/bin/bash
num="${1}"
echo $(( $1 + 1 ))
echo
echo $(( num + 1 ))
echo
[[ "${num}" -eq 42 ]] && echo "Correct (int)" || echo "Wrong (int)"
echo
[[ "${num}" = 42 ]] && echo "Correct (str)" || echo "Wrong (str)"
echo
declare -i num="${1}"
This script can execute arbitrary code:
./script.sh 'a[$(echo Gotcha >&2)]+42'
Gotcha
43
Gotcha
43
Gotcha
Correct (int)
Wrong (str)
Gotcha
Explanation:
It has to execute the string to convert (try
[[ 0xFF -eq 255 ]]) it to a number (-eq)However
=comparison is for strings, so we are safe in that case
x[y] would be treated as 0
* Possible attack vector: ``num=”$(curl -s https://api.coolsite.com/v1/number_of_people_who_think_im_cool)”
``
Similar issue with array indexing:
$ myarray=(7 8 9 10 11)
$ echo "${myarray[$(echo Gotcha >&2; echo 2)]}"
Gotcha
9
Script Template¶
#!/usr/bin/env bash
#: Your comments here.
set -o errexit
set -o nounset
set -o pipefail
work_dir=$(dirname "$(readlink --canonicalize-existing "${0}" 2> /dev/null)")
readonly conf_file="${work_dir}/script.conf"
readonly error_reading_conf_file=80
readonly error_parsing_options=81
readonly script_name="${0##*/}"
a_option_flag=0
abc_option_flag=0
flag_option_flag=0
trap clean_up ERR EXIT SIGINT SIGTERM
usage() {
cat <<USAGE_TEXT
Usage: ${script_name} [-h | --help] [-a <ARG>] [--abc <ARG>] [-f | --flag]
DESCRIPTION
Your description here.
OPTIONS:
-h, --help
Print this help and exit.
-f, --flag
Description for flag option.
-a
Description for the -a option.
--abc
Description for the --abc option.
USAGE_TEXT
}
clean_up() {
trap - ERR EXIT SIGINT SIGTERM
# Remove temporary files/directories, log files or rollback changes.
}
die() {
local -r msg="${1}"
local -r code="${2:-90}"
echo "${msg}" >&2
exit "${code}"
}
if [[ ! -f "${conf_file}" ]]; then
die "error reading configuration file: ${conf_file}" "${error_reading_conf_file}"
fi
# shellcheck source=script.conf
. "${conf_file}"
parse_user_options() {
local -r args=("${@}")
local opts
# The following code works perfectly for
opts=$(getopt --options a:,f,h --long abc:,help,flag -- "${args[@]}" 2> /dev/null) || {
usage
die "error: parsing options" "${error_parsing_options}"
}
eval set -- "${opts}"
while true; do
case "${1}" in
--abc)
abc_option_flag=1
readonly abc_arg="${2}"
shift
shift
;;
-a)
a_option_flag=1
readonly a_arg="${2}"
shift
shift
;;
--help|-h)
usage
exit 0
shift
;;
--flag|-f)
flag_option_flag=1
shift
;;
--)
shift
break
;;
*)
break
;;
esac
done
}
parse_user_options "${@}"
if ((flag_option_flag)); then
echo "flag option set"
fi
if ((abc_option_flag)); then # Check if the flag options are set or ON:
# Logic for when --abc is set.
# "${abc_arg}" should also be set.
echo "Using --abc option -> arg: [${abc_arg}]"
fi
if ((a_option_flag)); then
# Logic for when -a is set.
# "${a_arg}" should also be set.
echo "Using -a option -> arg: [${a_arg}]"
fi
exit 0
Testing¶
Exit Codes¶
Redirections¶
Working with JSON¶
$ printf '{"name": "%s", "sign": "%s"}' "$name" "$sign"
{"name": "jes", "sign": "aquarius"}
$ jo hostname=$(hostname) meta=$(jo user=$USER term=$TERM)
{"hostname":"localhost","meta":{"user":"root","term":"xterm-256color"}}
$ jq --null-input --arg username jdoe --arg password '$ec\ret"' '{"username":$username, "password":$password}'
{
"username": "jdoe",
"password": "$ec\\ret\""
}
Network¶
(Core)utils¶
Find¶
exec- run util and pass args (-exec command {} suffix) +command- the util +{}- unfolds to the “files found” +;- suffix, run a command for each file (either armour\;or quote';') ++- suffix, run a command for a group of filesexecdir-cdto the dir with the file, before running
Test + and \; with:
find ~/ -type f -exec sh -c 'echo $$' {} \; # many pids
find ~/ -type f -exec sh -c 'echo $$' {} + # less pids
find ~/ -type f -print0 | xargs -0 -n4 -P2 sh -c 'echo $$'
0- args are separated by NUL ASCII. Special chars (e.g.\and') treated as regular charsn- max num of arguments passed at onceP- max num of processes running simultaneously, 0 for unlimitedcommand- external command. If not specified, displays the argument groups
xargs accepts SIGUSR1/SIGUSR2 to increase/reduce the number of simultaneously running processes