#!/usr/bin/env bash shopt -s nullglob # Utility functions is_function() [[ "$(type -t "$1" 2>/dev/null)" == 'function' ]] readonly -f is_function usage() { cat <<- EOF Usage: ssm EOF }; readonly usage; var() { declare var_function=$1; shift declare var_name # This enforces bash's grammar against things like # var 'cat /etc/shadow; foo' ... [[ $var_function =~ ^[a-zA-Z_][a-zA-Z0-9_]+?$ ]] || { die 73 "On line $LINENO, in $FUNCNAME: Invalid identifier: '$var_function'" } if [[ "$1" == '-v' ]]; then var_name=$2 shift 2 else var_name=$var_function fi if ! is_function "$var_function"; then eval " ${var_function}() { declare mode=set declare -n _var=\"${var_name}\" if (( \$# )); then case \"\$1\" in ('=') mode=set;; ('+=') mode=append;; ('_=') mode=prepend;; (':=') mode=default;; ('==') mode=compare;; ('=~') mode=regex;; ('is') mode=\"is_\$2\";; ('u') mode=includes;; (*) die 71 \"Syntax error in ${var_function}!\";; esac shift else mode='bool' fi case \"\$mode\" in (set) _var=( \"\$@\" );; (append) _var+=( \"\$@\" );; (prepend) _var=( \"\$@\" \"\${var[@]}\" );; (default) [[ \"\$_var\" ]] || _var=( \"\$@\" );; (compare) [[ \"\$_var\" == \"\$*\" ]];; (regex) [[ \"\$_var\" =~ \$@ ]];; (bool) case \"\${_var,,}\" in (''|'false'|'0') return 1;; (*) return 0;; esac ;; (includes) for i in \"\${_var[@]}\"; do [[ \"\$i\" == \"\$*\" ]] && return 0 done return 1 ;; (is_fs_object) [[ -e \"\$_var\" ]];; (is_file) [[ -f \"\$_var\" ]];; (is_dir|is_directory) [[ -d \"\$_var\" ]];; (is_empty) [[ -z \"\${_var[*]}\" ]];; (*) die 71 \"Syntax error in ${var_function}!\";; esac }; readonly -f \"${var_function}\" ${var_function}++() { declare -n _var=\"${var_name}\" (( _var++ )) } ${var_function}--() { declare -n _var=\"${var_name}\" (( _var-- )) } " fi if (( $# )); then case "$1" in ('='|'=='|'=~'|'+='|'_='|':=') "$var_function" "$@" ;; (*) for v in "$@"; do var "$v" done ;; esac fi }; readonly -f var ## Die. Why not? die() { declare code=${1:-0} [[ "$2" ]] && printf '%s\n' "$2" >&2 exit "$code" }; readonly -f die if_service_action() { for f in "service::$1" "$1"; do is_function "$f" && return 0 done return 1 }; readonly -f if_service_action run_service_action() { for f in "service::$1" "$1"; do is_function "$f" && { "$f"; return $? } done return 0 }; readonly -f run_service_action spawn() { if [[ $service_logfile_out == "$service_logfile_err" ]]; then exec 3>"$service_logfile_out" else exec 3>"$service_logfile_err" fi exec "$@" >"$service_logfile_out" 2>&3 }; readonly spawn; cgroup_get_procs() { if service_cgroup_path is dir; then mapfile -t service_cgroup_procs < "$cgroup_home/$service_cgroup_name/cgroup.procs" fi }; readonly -f cgroup_get_procs ## Run the command and wait for it to die svc() { declare job_pid job_exit job_success last_respawn fail_counter date counter p var job_pid job_exit job_success last_respawn fail_counter date counter p svc::cleanup() { #nullexec kill -n "$service_stop_signal" "$job_pid" nullexec "${service_stop_command[@]}" anywait "$job_pid" "$service_stop_timeout" # Cgroup stuff if cgroups; then if service_cgroup_cleanup; then cgroup_get_procs service_cgroup_procs is empty || { for p in "${service_cgroup_procs[@]}"; do p == "$BASHPID" || { nullexec kill -n "$service_cgroup_kill_signal" "$p" anywait "$p" "$service_stop_timeout" & } done wait || die $? } fi fi run_service_action 'cleanup' rm -f "$svc_pidfile" "$service_pidfile" "$service_ready_flag" die "$job_exit" }; trap 'svc::cleanup' TERM printf '%s' $BASHPID > "$svc_pidfile" # Cgroups if cgroups; then mkdir -p "$cgroup_home/$service_cgroup_name" echo "$BASHPID" > "$cgroup_home/$service_cgroup_name/cgroup.procs" fi while true; do job_success = 0 # Needs to be reset # Remove stale pidfiles some services may leave behind # If a pidfile exists at this point in the code, it should be stale. if service_pidfile_remove_stale; then if ! svc_pidfile == "$service_pidfile"; then service_pidfile is file && rm -f "$service_pidfile" fi fi # Some setup might be required on each loop run_service_action 'setup' # Spawn the process and record the PID spawn "$@" & job_pid = "$!" # Wait for the process to exit and record the exit code # This depends on a few things if service_own_pidfile; then # We need to wait for the service to write down its pidfile until service_pidfile is file; do (( counter >= service_pidfile_timeout*10 )) && { printf '127' > "$service_exit_file" break } counter++ sleep 0.1 done read -r job_pid < "$service_pidfile" # We consider any termination of a service with its own pidfile # to be a failure anywait "$job_pid"; job_exit=127 else printf '%s' "$job_pid" > "$service_pidfile" wait "$job_pid"; job_exit=$? fi # One service failure, two service failures... if service_success_exit u "$job_exit"; then job_success = 1 (( fail_counter )) && fail_counter=0 else job_success = 0 fail_counter++ fi # Record the exit code printf '%s' "$job_exit" > "$service_exit_file" # Back off if the service exits too much AND too quickly. if ! service_respawn_force; then if (( fail_counter >= 3 )); then printf -v date '%(%s)T' (( (date - last_respawn) <= 5 )) && break fi fi # Respawn, if necessary service_respawn_flag || break case $service_respawn in (on-success) job_success || break;; (on-failure) job_success && break;; esac # Record the time every time we restart the loop printf -v last_respawn '%(%s)T' done svc::cleanup }; readonly -f svc ## Run a command with its output discarded nullexec() { eval "$@" &>/dev/null; } readonly -f nullexec ## Wait for a pid, indefinitely anywait() { declare counter timeout var counter = 0 var timeout = "$2" while nullexec kill -0 "$1"; do timeout && { (( counter >= timeout )) && return 1 counter++ } sleep 0.1 done return 0 }; readonly -f anywait ## Simple timer timer() { declare cnt timeout var cnt = 0 var timeout = "$1" shift while ! "$@"; do (( cnt >= (timeout*10) )) && return 1 sleep 0.1 cnt++ done return 0 }; readonly -f timer ## Set a service's ready flag. set_ready() { printf '1' > "$service_ready_flag"; } readonly -f set_ready ## Is a service ready? is_ready() { service_ready_flag is file; } readonly -f is_ready ## Wait for this service to get ready wait_ready() { timer "$service_ready_timeout" is_ready; } readonly -f wait_ready ## Depend on other services to be started depend() { declare s for s in "$@"; do if ! "$_self" "$s" qstatus; then nullexec "$_self" "$s" start || { failed_deps += "$s" return 1 } fi done }; readonly -f depend ## Create tmpfiles mktmpfiles() { declare f for f in "${service_tmpfiles[@]}"; do IFS=':' read -r f_path f_type f_args <<< "$f" if ! [[ -e $f_path ]]; then case "$f_type" in symlink) ln -s "$f_args" "$f_path";; file|dir) IFS=':' read -r f_perms f_owner f_group <<< "$f_args" if [[ $f_type == 'file' ]]; then > "$f_path" chmod "${f_perms:-644}" "$f_path" elif [[ "$f_type" == 'dir' ]]; then mkdir -p -m "${f_perms:-755}" "$f_path" fi if [[ "$f_owner" || "$f_group" ]]; then chown "${f_owner:-root}:${f_group:-root}" "$f_path" fi;; esac fi done }; readonly -f mktmpfiles ## Depend on other services to be ready depend_ready() { declare s depend "$@" || return 1 for s in "$@"; do "$_self" "$s" wait_ready || { failed_deps += "$s" return 1 } done }; readonly -f depend_ready result() { declare rc; var rc = "$1"; shift declare -A msgs while (( $# )); do [[ "$2" ]] || return 1 msgs["$1"]="$2" shift 2 done [[ "${msgs[$rc]}" ]] || msgs["$rc"]="Failed!" printf '%s\n' "${msgs[$rc]}" >&2 }; readonly -f result # Overloadable functions ## Start the service, write down the svc pid start() { service_running && return 3 service_enabled && return 19 service_command is file || return 9 # Preform cgroup checks if cgroups; then if service_cgroup_exclusive; then service_cgroup_procs is empty || return 15 fi fi depend "${service_depends[@]}" || return 7 depend_ready "${service_depends_ready[@]}" || return 7 rm -f "$service_stopped_flag" mktmpfiles || return 13 svc "${service_command[@]}" & job=$! if service_oneshot; then wait "$job"; res=$? (( res )) && { printf '%s' "$res" > "$service_exit_file" return 17 } fi if timer "$service_ready_timeout" run_service_action 'ready'; then set_ready else return 5 fi return 0 } ## Reload the service ## Usually just sends HUP reload() { service_running || return 3 nullexec kill -n "$service_reload_signal" "$service_pid" } ## Stop the service ## Returns: ## 3: Service is not running. stop() { if service_oneshot; then return 7 else service_running || return 3 kill -n 15 "$svc_pid" || return 1 anywait "$svc_pid" "$service_stop_timeout" || return 5 > "$service_stopped_flag" # Cgroup stuff if cgroups; then if service_cgroup_kill; then service_cgroup_wait = 1 for p in "${service_cgroup_procs[@]}"; do nullexec kill -n "$service_cgroup_kill_signal" "$p" done fi if service_cgroup_wait; then for p in "${service_cgroup_procs[@]}"; do anywait "$p" "$service_stop_timeout" & done wait || return 5 fi fi return 0 fi } info.item() { printf "%16s: %s\n" "$1" "$2"; } info() { declare -a _info_items declare _status_code _status _show_pid var _info_items _status_code _status _show_pid info.item Name "$service_name" status; _status_code = "$?" if service_oneshot; then infoinfo.item Oneshot 'yes' case $_status_code in (0) _status = 'success';; (1) _status = 'not enabled';; (9) _status = "failed ($service_exit_last)";; (*) _status = 'unknown';; esac else info.item Restart "$service_respawn" case $_status_code in (0) _status = 'running';; (1) _status = 'down';; (7) _status = 'stopped';; (9) _status = "failed ($service_exit_last)";; (11) _status = "exited ($service_exit_last)";; (*) _status = 'unknown';; esac fi info.item Status "$_status" info.item Exec "${service_command[*]}" info.item Config "$service_config" info.item Workdir "$service_workdir" info.item 'Output log' "$service_logfile_out" service_logfile_out == "$service_logfile_err" || \ info.item 'Error log' "$service_logfile_err" if service_running; then info.item PID "$service_pid" info.item PIDFile "$service_pidfile" cgroups && { info.item Cgroup "$cgroup_home/$service_cgroup_name" info.item 'Cgroup procs' "${#service_cgroup_procs[@]}" } fi return 0 } ## Restart just calls the script twice by default restart() { "$_self" "$service_name" stop "$_self" "$service_name" start } edit() { $EDITOR "$service_config"; } logs() { if service_logfile_out == "$service_logfile_err"; then $PAGER "$service_logfile_out" else printf '%s\n' "$service_logfile_out" "$service_logfile_err" fi } ## Status is a bit of a special case. It's talkative. status() { service_running && return 0 service_enabled && return 0 service_stopped && return 7 service_failed && return 9 service_exit_last is empty || return 11 return 1 } ## For use in scripts qstatus() { nullexec status; } ## By default there is no ready check ready() { :; } ## Reset failes reset-exit() { rm -f "$service_exit_file"; } # Main code ## Empty declarations var service_pid \ service_pidfile \ service_type \ service_depends \ service_depends_ready \ service_command \ service_config \ service_path \ service_name \ service_args \ service_logfile_out \ service_logfile_err \ service_ready_flag \ service_stopped_flag \ service_exit_file \ service_exit_last \ service_cgroup_name \ service_cgroup_procs \ service_cgroup_path \ service_config_current \ cgroup_home \ failed_deps \ svc_pidfile \ svc_pid \ cfg_path \ cfg_file \ cfg_dir \ rundir \ logdir \ action \ _self \ res ## Internal defaults var flag_list_services = 0 var flag_edit_service = 0 var flag_reset_exit = 0 var flag_reread_service = 0 var flag_forget_service = 0 var flag_load_service = 0 var flag_no_netns = 0 ## check for some environment stuff var EDITOR := 'vim' var PAGER := 'less' var XDG_CONFIG_HOME := "$HOME/.config" var XDG_RUNTIME_DIR := "/run/user/$UID" ## Let's set some defaults # These are meaningful to reconfigure. var service_respawn = 'no' # Respawn the service if it exits var service_workdir = '/' var service_stop_timeout = 30 var service_stop_command = kill -n "\$service_stop_signal" "\$job_pid" var service_ready_timeout = 15 var service_reload_signal = 1 var service_stop_signal = 15 var service_cgroup_exclusive = 0 # Refuse to start the service if its cgroup is not empty var service_cgroup_wait = 0 # Wait on all the members of the cgroup to exit when stopping the service. var service_cgroup_strict = 1 # Enable checking if the main service PID is in the correct cgroup before doing anythin with the service var service_cgroup_kill = 0 # Kill the entire cgroup when stopping the service. var service_cgroup_kill_signal = 15 # The signal to send to the stray cgroup members. var service_cgroup_cleanup = 0 # Clean up the cgroup when the main PID exits. Uses service_cgroup_kill_signal. var service_success_exit = 0 # Array, takes exit codes that are to be treated as successful termination. var service_pidfile_timeout = 15 # How long to wait for unmanaged services to create their pidfiles. var service_pidfile_remove_stale = 1 # Remove stale pidfiles from unmanaged services. var service_remember = 1 # Copy the config into ssm's runtime dir. # Global config var cgroups = 0 # Enable cgroup-related functions var usrdir = '/usr/share/ssm' # These are not var service_own_pidfile = 0 var service_oneshot = 0 var service_running = 0 var service_enabled = 0 var service_stopped = 0 var service_failed = 0 var service_nologs = 0 var service_respawn_flag = 0 var service_respawn_force = 0 # These depend on who we are if (( $UID )); then rundir = "$XDG_RUNTIME_DIR/ssm" logdir = "$HOME/log/ssm" cgroup_home = "/sys/fs/cgroup/user/$UID/ssm" cfg_file = "$XDG_CONFIG_HOME/ssm/ssm.conf" else rundir = '/run/ssm' logdir = '/var/log/ssm' cgroup_home = "/sys/fs/cgroup/ssm" cfg_file = '/etc/ssm/ssm.conf' fi ## Figure out our full path case "$0" in (/*) _self = "$0";; (*) _self = "$PWD/$0";; esac # Source the config if cfg_file is file; then source "$cfg_file" || die 37 "Failed to load config: $cfg_file" fi # Common config path cfg_path = "$XDG_CONFIG_HOME/ssm" '/etc/ssm' # Load custom config and functions, reversing the PATH order for (( idx=${#cfg_path[@]}-1; idx>=0; idx-- )); do cfg_dir = "${cfg_path[idx]}" for f in "$cfg_dir/functions"/*; do source "$f" || die 9 "Failed to source functions from $f" done done # Parse arguments while (( $# )); do case $1 in (-h|--help) # Show help usage; exit 0;; (-L|--list-services) # List all services flag_list_services = 1;; (--reset-exit) # Reset last exit status flag_reset_exit = 1;; (-e|--edit) # Edit the service file. flag_edit_service = 1;; (-r|--reread) # Reload the service file. flag_reread_service = 1;; (-f|--forget) # Unload a service. flag_forget_service = 1;; (-l|--load) # Load a service. flag_load_service = 1;; (-i|--info) action = 'info';; (--no-netns) flag_no_netns = 1;; (--) shift; break;; (-*) printf 'Unknown key: %s\n' "$1" >&2; exit 1;; (*) break;; esac shift done # Now create the needed runtime stuff for d in "$rundir" "$rundir/current" "$logdir"; do mkdir -p "$d" || die 3 "Failed to create runtime dir: $d" done # Common service path if (( UID )); then service_path += "$rundir/current" "$XDG_CONFIG_HOME/ssm/services" '/etc/ssm/services' "$usrdir/services" else service_path += "$rundir/current" '/etc/ssm/services' "$usrdir/services" fi # Special actions if flag_list_services; then var known_services for i in "$rundir"/current/*; do i_name="${i##*/}" known_services u "$i_name" || known_services += "$i_name" done for s in "${known_services[@]}"; do printf '%s: ' "$s" ssm "$s" status done die 0 fi # This script requires at least one argument (( $# >= 1 )) || { usage; exit 2; } # Unless otherwise specified service_name = "$1"; shift # Find the current loaded service and remove it if necessary service_config_current = "$rundir/current/$service_name" if flag_reread_service; then rm -vf "$service_config_current" >&2 || die "$?" "Abort!" fi # Find the service if [[ $service_name == /* ]]; then service_config = "$service_name" service_name = "${service_name##*/}" else for i in "${service_path[@]/%//$service_name}"; do [[ -f "$i" ]] && { service_config = "$i" break } done fi # We really don't want this overriden readonly service_name # Unload the service and leave if asked. if flag_forget_service; then if service_config_current is file; then rm -f "$service_config_current" || die "$?" "Abort!" die 0 "Forgot service: $service_name" else die 90 "Service not currently known: $service_name" fi fi # Die if there is no service config file service_config || die 19 "Service not found: $service_name" # Edit the service config flag_edit_service && { edit; die $?; } # These depend on the service_name and make little sense to reconfigure. service_ready_flag := "$rundir/$service_name.ready" service_stopped_flag := "$rundir/$service_name.stopped" service_exit_file := "$rundir/$service_name.exit" service_cgroup_name := "$service_name" service_cgroup_path := "$cgroup_home/$service_name" # Get the service defaults for p in "${cfg_path[@]/%//$service_name}"; do [[ -f "$p" ]] && { source "$p" || die 5 "Failed to read service defaults: $p" } done # Reset the exit status flag_reset_exit && { reset-exit; die $?; } # Get the service config source -- "$service_config" "${@:3}" || die 7 "Failed to read the service config: $service_config" # Rexec ourselves in the requested netns flag_no_netns || { [[ $service_netns ]] && exec ip netns exec "$service_netns" "$0" --no-netns "$service_name" "$@" } # “Load” the service into memory if ! service_config == "$service_config_current" && service_remember; then cat "$service_config" > "$rundir/current/$service_name" printf 'Loaded %s from: %s\n' "$service_name" "$service_config" >&2 service_config = "$rundir/current/$service_name" fi # Die if we only needed to load a service flag_load_service && die 0 # Legacy service_args && service_command += "${service_args[@]}" service_type == 'oneshot' && service_oneshot = 1 service_respawn == 1 && service_respawn = always # Set respawn flag if ! service_respawn == 'no'; then case $service_respawn in (on-failure|on-success|always) service_respawn_flag = 1;; (*) die 88 "Wrong value for service_respawn";; esac fi if service_respawn_flag && service_oneshot; then die 89 'Cowardly refusing to respawn a oneshot service' fi # Catch services with their own pidfile, set the appropriate flag. service_pidfile && service_own_pidfile = 1 # Semi-hardcoded stuff svc_pidfile = "$rundir/$service_name.svc_pid" # Service-level defaults service_pidfile := "$rundir/$service_name.pid" service_logfile_out := "$logdir/${service_name}.log" service_logfile_err := "$service_logfile_out" service_success_exit := 0 # A shortcut for disabling logging if service_nologs; then service_logfile_out = '/dev/null' service_logfile_err = '/dev/null' fi # Get the last recorded mainpid if service_pidfile is file; then read -r service_pid < "$service_pidfile" fi # Let's see if there's an svc running if svc_pidfile is file; then read -r svc_pid < "$svc_pidfile" # Let's see if it's running if nullexec kill -0 "$svc_pid"; then service_running = 1 # If it's running, we know its PID probably: if service_pid; then if ! nullexec kill -0 "$service_pid"; then printf 'WARNING: The recorded service main PID (%s) is not running.\n' "$service_pid" >&2 fi else printf 'WARNING: No service pidfile found; service PID unknown.\n' "$service_pidfile" >&2 fi else if nullexec kill -0 "$service_pid"; then die 75 "ERROR: No svc active for $service_name, but its last recorded PID ($service_pidfile) is currenlty running: ${service_pid}." fi # Remove the stale svc pidfile printf 'WARNING: Removing a stale svc pidfile: %s\n' "$svc_pidfile" >&2 rm -f "$svc_pidfile" fi fi # Let's see if the service was deliberately stopped if service_stopped_flag is file; then # Ooh, it was. service_stopped = 1 fi # Check if the service has failed if service_exit_file is file; then read -r service_exit_last < "$service_exit_file" if service_success_exit u "$service_exit_last"; then service_oneshot && service_enabled = 1 else # :( service_failed = 1 fi fi # Check cgroups, if enabled if cgroups; then cgroup_get_procs # If there's a service PID, check if it's in the service's cgroup if service_cgroup_strict; then if service_running; then service_cgroup_procs u "$service_pid" || die 29 "Recorded service PID is not in the service's cgroup, bailing!" fi fi fi # Action! [[ "$1" ]] && action = "$1" action || { flag_reread_service && die 0 # Not a mistake if the service was to be re-read usage; die 2; } # Do we have such a function? if_service_action "$action" || die 17 "Function $action is not defined for $service_name." # cd into the workdir, if defined. service_workdir && { cd "$service_workdir" || die $? } # Run pre_$action function run_service_action "pre_$action" || die 13 "pre_$action failed!" # Run the main action run_service_action "$action"; res = "$?" case "$action" in stop) result "$res" \ 0 "Stopped $service_name" \ 3 "$service_name is not running" \ 5 "Operation timed out" \ 7 "Can't “stop” a oneshot service. Use reset-exit if you want to “start” the service again" ;; start) result "$res" \ 0 "Started $service_name" \ 3 "$service_name is already running" \ 5 "Readyness check for $service_name timed out" \ 7 "Failed to start dependencies for $service_name: ${failed_deps[@]}" \ 9 "service_command does not exist: ${service_command[0]}" \ 13 "Failed to create temporary files for $service_name" \ 15 "Refusing to start $service_name: the service cgroup is not empty and \$service_cgroup_exclusive is set" \ 17 "$service_name failed" \ 19 "$service_name is already enabled" ;; reload) result "$res" \ 0 "Reloading $service_name" ;; status) if service_oneshot; then result "$res" \ 0 "$service_name was successful" \ 1 "$service_name is not enabled" \ 9 "$service_name has failed ($service_exit_last)" else result "$res" \ 0 "$service_name is running" \ 1 "$service_name is not running" \ 7 "$service_name was stopped" \ 9 "$service_name has failed with code: $service_exit_last" \ 11 "$service_name has exited with code: $service_exit_last" fi ;; esac (( res )) && exit "$res" # Run post_$action function run_service_action "post_$action" || die 15 "post_$action has failed!"