#!/bin/sh
# unitc - a curl wrapper for configuring NGINX Unit
# https://github.com/nginx/unit/tree/master/tools
# NGINX, Inc. (c) 2025

umask 077 # Temporary files can hold the configuration

# Defaults
#
ERROR_LOG=/dev/null
REMOTE=0
SHOW_LOG=1
NOLOG=0
QUIET=0
CONVERT=0
URI=""
RPC_CMD=""
METHOD=PUT
CONF_FILES=""
EDIT_FILENAME=""
OUTPUT_STATUS=0

while [ $# -gt 0 ]; do
	OPTION=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')
	case $OPTION in
		"-F" | "--FORMAT")
			case $(printf '%s' "$2" | tr '[:lower:]' '[:upper:]') in
				"YAML")
					CONVERT=1
					if hash yq 2> /dev/null; then
						CONVERT_TO_JSON="yq eval -P --output-format=json"
						CONVERT_FROM_JSON="yq eval -P --output-format=yaml"
					else
						echo "${0##*/}: ERROR: yq(1) is required to use YAML format; install at <https://github.com/mikefarah/yq#install>" >&2
						exit 1
					fi
					;;
				"")
					echo "${0##*/}: ERROR: Must specify configuration format" >&2
					exit 1
					;;
				*)
					printf '%s\n' "${0##*/}: ERROR: Invalid format ($2)" >&2
					exit 1
					;;
			esac
			shift; shift
			;;

		"-H" | "--HELP")
			shift
			;;

		"-L" | "--NOLOG" | "--NO-LOG")
			NOLOG=1
			shift
			;;

		"-Q" | "--QUIET")
			QUIET=1
			shift
			;;

		"GET" | "PUT" | "POST" | "DELETE" | "INSERT" | "EDIT")
			METHOD=$OPTION
			shift
			;;

		"HEAD" | "PATCH" | "PURGE" | "OPTIONS")
			printf '%s\n' "${0##*/}: ERROR: Invalid HTTP method ($OPTION)" >&2
			exit 1
			;;

		*)
			if [ -f "$1" ] && [ -r "$1" ]; then
				CONF_FILES="$CONF_FILES
$1"
				if [ "${1##*.}" = "yaml" ]; then
					printf '%s\n' "${0##*/}: INFO: converting $1 to JSON"
					shift; set -- "--format" "yaml" "$@" # Apply the command line option
				else
					shift
				fi
			elif [ "${1#/}" != "$1" ] || [ "${1#http}" != "$1" ] && [ "$URI" = "" ]; then
				URI=$1
				shift
			elif [ "${1#ssh://}" != "$1" ] || [ "${1#docker://}" != "$1" ]; then
				UNIT_CTRL=$1
				shift
			else
				printf '%s\n' "${0##*/}: ERROR: Invalid option ($1)" >&2
				exit 1
			fi
			;;
	esac
done

if [ "$URI" = "" ]; then
	cat << __EOF__
${0##*/} - a curl wrapper for managing NGINX Unit configuration

USAGE: ${0##*/} [options] URI

• URI is for Unit's control API target, e.g. /config
• A local Unit control socket is detected unless a remote one is specified.
• Configuration data is read from stdin.
• All options are case-insensitive (excluding filenames and URIs).

General options
  filename …          # Read configuration data from files instead of stdin
  HTTP method         # Default=GET, or PUT when config data is present
  EDIT                # Opens the URI contents in \$EDITOR
  INSERT              # Virtual HTTP method; prepend data to an array
  -f | --format YAML  # Convert configuration data to/from YAML format
  -q | --quiet        # No output to stdout

Local options
  -l | --nolog        # Do not monitor the Unit log file after config changes

Remote options
  ssh://[user@]remote_host[:port]/path/to/control.socket  # Remote Unix socket
  http://remote_host:port/URI                             # Remote TCP socket
  docker://container_ID[/non-default/control.socket]      # Container on host

 A remote Unit instance may also be defined with the \$UNIT_CTRL environment
 variable as http://remote_host:port  or  ssh://…  or  docker://…  (as above).

__EOF__
	exit 1
fi

# Figure out if we're running on the Unit host, or remotely
#
if [ "$UNIT_CTRL" = "" ]; then
	if [ "${URI#http}" != "$URI" ]; then
		REMOTE=1
		UNIT_CTRL=$(printf '%s\n' "$URI" | cut -f1-3 -d/)
		URI=/$(printf '%s\n' "$URI" | cut -f4- -d/)
	fi
elif [ "${UNIT_CTRL#ssh://}" != "$UNIT_CTRL" ]; then
	REMOTE=1
	RPC_CMD="ssh $(printf '%s\n' "$UNIT_CTRL" | cut -f1-3 -d/)"
	UNIT_CTRL="--unix-socket /$(printf '%s\n' "$UNIT_CTRL" | cut -f4- -d/) _"
elif [ "${UNIT_CTRL#docker://}" != "$UNIT_CTRL" ]; then
	RPC_CMD="docker exec -i $(printf '%s\n' "$UNIT_CTRL" | cut -f3 -d/)"
	DOCKSOCK=/$(printf '%s\n' "$UNIT_CTRL" | cut -f4- -d/)
	if [ "$DOCKSOCK" = "/" ]; then
		DOCKSOCK="/var/run/control.unit.sock" # Use default location if no path
	fi
	UNIT_CTRL="--unix-socket $DOCKSOCK _"
	REMOTE=1
elif [ "${URI#/}" != "$URI" ]; then
	REMOTE=1
fi

if [ $REMOTE -eq 0 ]; then
	# Check if Unit is running, find the main process
	#
	# shellcheck disable=SC2009,SC2046
	set -- $(ps ax | grep unit:\ main | grep -v \ grep | awk '{print $1}')
	if [ $# -eq 0 ]; then
		echo "${0##*/}: ERROR: unitd not running (set \$UNIT_CTRL to configure a remote instance)" >&2
		exit 1
	elif [ $# -gt 1 ]; then
		echo "${0##*/}: ERROR: multiple unitd processes detected ($*)" >&2
		exit 1
	fi
	PID=$1

	# Read the significant unitd conifuration from cache file (or create it)
	#
	if [ -r "/tmp/${0##*/}.$PID.env" ]; then
		# shellcheck source=/dev/null
		. "/tmp/${0##*/}.$PID.env"
	else
		# Check we have all the tools we will need (that we didn't already use)
		#
		MISSING=$(hash curl tr cut sed tail sleep 2>&1 | cut -f4 -d: | tr -d '\n')
		if [ "$MISSING" != "" ]; then
			echo "${0##*/}: ERROR: cannot find$MISSING: please install or add to \$PATH" >&2
			exit 1
		fi

		# Obtain any optional startup parameters from the 'unitd: main' process
		# so we can get the actual control address and error log location.
		# Command line options and output of ps(1) is notoriously variable across
		# different *nix/BSD platforms so multiple attempts might be needed.
		#
		PARAMS=$( (ps -wwo args=COMMAND -p "$PID" || ps "$PID") 2> /dev/null | grep unit | tr '[]' ^ | cut -f2 -d^ | sed -e 's/ --/\n--/g')
		if [ "$PARAMS" = "" ]; then
			echo "${0##*/}: WARNING: unable to identify unitd command line parameters for PID $PID, assuming unitd defaults from \$PATH" >&2
			PARAMS=unitd
		fi
		CTRL_ADDR=$(printf '%s\n' "$PARAMS" | grep '\--control ' | cut -f2 -d' ')
		if [ "$CTRL_ADDR" = "" ]; then
			CTRL_ADDR=$($(printf '%s\n' "$PARAMS") --help | grep -A1 '\--control ADDRESS' | tail -1 | cut -f2 -d\")
		fi
		if [ "$CTRL_ADDR" = "" ]; then
			echo "${0##*/}: ERROR: cannot detect control socket. Did you start unitd with a relative path? Try starting unitd with --control option." >&2
			exit 2
		fi

		# Prepare for network or Unix socket addressing
		#
		if [ "$(printf '%s\n' "$CTRL_ADDR" | grep -c ^unix:)" -eq 1 ]; then
			SOCK_FILE=$(printf '%s\n' "$CTRL_ADDR" | cut -f2- -d:)
			if [ -r "$SOCK_FILE" ]; then
				UNIT_CTRL="--unix-socket $SOCK_FILE _"
			else
				printf '%s\n' "${0##*/}: ERROR: cannot read unitd control socket: $SOCK_FILE" >&2
				ls -l "$SOCK_FILE"
				exit 2
			fi
		else
			UNIT_CTRL="http://$CTRL_ADDR"
		fi

		# Get error log filename
		#
		ERROR_LOG=$(printf '%s\n' "$PARAMS" | grep '\--log' | cut -f2 -d' ')
		if [ "$ERROR_LOG" = "" ]; then
			ERROR_LOG=$($(printf '%s\n' "$PARAMS") --help | grep -A1 '\--log' | tail -1 | cut -f2 -d\")
		fi
		if [ "$ERROR_LOG" = "" ]; then
			echo "${0##*/}: WARNING: cannot detect unit log file (will not be monitored). If you started unitd from a relative path then try using the --log option." >&2
			ERROR_LOG=/dev/null
		fi

		# Cache the discovery for this unit PID (and cleanup any old files)
		#
		rm -f /tmp/"${0##*/}".* 2> /dev/null
		printf '%s\n' "UNIT_CTRL=\"${UNIT_CTRL}\"" > "/tmp/${0##*/}.$PID.env"
		printf '%s\n' "ERROR_LOG=\"${ERROR_LOG}\"" >> "/tmp/${0##*/}.$PID.env"
	fi
fi

# Choose presentation style
#
if [ $QUIET -eq 1 ]; then
	OUTPUT="tail -c 0" # Equivalent to >/dev/null
elif [ $CONVERT -eq 1 ]; then
	OUTPUT=$CONVERT_FROM_JSON
elif hash jq 2> /dev/null; then
	OUTPUT="jq"
else
	OUTPUT="cat"
fi

# Get current length of error log before we make any changes
#
if [ -f "$ERROR_LOG" ] && [ -r "$ERROR_LOG" ]; then
	LOG_LEN=$(wc -l < "$ERROR_LOG")
else
	NOLOG=1
fi

# Set the base curl command after testing for newer features
#
if $RPC_CMD curl --fail-with-body --version > /dev/null 2>&1; then
	CURL_CMD="$RPC_CMD curl --silent --fail-with-body"
else
	CURL_CMD="$RPC_CMD curl --silent --fail"
fi

# Concatenate the configuration files, or read stdin when there are none
#
cat_conf_files() {
	# shellcheck disable=SC2086
	(IFS='
'; set -f; cat $CONF_FILES)
}

# Keep the curl exit status ($1) and show the response that curl saved
#
# The status of the output filter is kept too: a response that is not
# valid JSON is a failure the caller has to see, not a zero exit after
# a jq error on stderr.  The control API answers in JSON in every case;
# yq only changes the presentation.
#
show_response() {
	CURL_STATUS=$1
	$OUTPUT < "/tmp/${0##*/}.$$.out"
	OUTPUT_STATUS=$?
	rm -f "/tmp/${0##*/}.$$.out"
}

# Adjust HTTP method and curl params based on presence of stdin payload
#
if [ -t 0 ] && [ -z "$CONF_FILES" ]; then
	if [ "$METHOD" = "DELETE" ]; then
		# shellcheck disable=SC2086
		$CURL_CMD -X "$METHOD" $UNIT_CTRL"$URI" > "/tmp/${0##*/}.$$.out" 2> "/tmp/${0##*/}.$$"
		show_response $?
	elif [ "$METHOD" = "EDIT" ]; then
		EDITOR=$(test "$EDITOR" && printf '%s' "$EDITOR" || command -v editor || command -v vim || echo vi)
		EDIT_FILENAME=/tmp/${0##*/}.$$$(printf '%s' "$URI" | tr '/' '_')
		# shellcheck disable=SC2086
		$CURL_CMD -S $UNIT_CTRL"$URI" > "$EDIT_FILENAME" || exit 2
		if [ "${URI#/js_modules/}" != "$URI" ]; then
			if ! hash jq 2> /dev/null; then
				echo "${0##*/}: ERROR: jq(1) is required to edit JavaScript modules; install at <https://stedolan.github.io/jq/>" >&2
				exit 1
			fi
			jq -r < "$EDIT_FILENAME" > "$EDIT_FILENAME.js" # Unescape linebreaks for a better editing experience
			cp "$EDIT_FILENAME.js" "/tmp/${0##*/}.$$bak"
			$EDITOR "$EDIT_FILENAME.js" || exit 2
			# Test if this module is enabled
			# shellcheck disable=SC2086
			if $CURL_CMD $UNIT_CTRL/config/settings/js_module > "/tmp/${0##*/}.$$_js_module"; then
				# Remove the references, delete old module, push new module+reference
				# shellcheck disable=SC2086
				$CURL_CMD -X DELETE $UNIT_CTRL/config/settings/js_module && \
				$CURL_CMD -X DELETE $UNIT_CTRL"$URI" 2> "/tmp/${0##*/}.$$" && \
				printf "%s" "$(cat "$EDIT_FILENAME.js")" | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL"$URI" 2> "/tmp/${0##*/}.$$" && \
				cat "/tmp/${0##*/}.$$_js_module" | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL/config/settings/js_module 2> "/tmp/${0##*/}.$$"
			else
				# Delete then re-apply the module
				# shellcheck disable=SC2086
				$CURL_CMD -X DELETE $UNIT_CTRL"$URI" 2> "/tmp/${0##*/}.$$" && \
				printf "%s" "$(cat "$EDIT_FILENAME.js")" | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL"$URI" 2> "/tmp/${0##*/}.$$"
			fi
			CURL_STATUS=$?
		elif [ $CONVERT -eq 1 ]; then
			$CONVERT_FROM_JSON < "$EDIT_FILENAME" > "$EDIT_FILENAME.yaml"
			$EDITOR "$EDIT_FILENAME.yaml" || exit 2
			# shellcheck disable=SC2086
			$CONVERT_TO_JSON < "$EDIT_FILENAME.yaml" | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL"$URI" > "/tmp/${0##*/}.$$.out" 2> "/tmp/${0##*/}.$$"
			show_response $?
		else
			tr -d '\r' < "$EDIT_FILENAME" > "$EDIT_FILENAME.json" # Remove carriage-return from newlines
			$EDITOR "$EDIT_FILENAME.json" || exit 2
			# shellcheck disable=SC2086
			cat "$EDIT_FILENAME.json" | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL"$URI" > "/tmp/${0##*/}.$$.out" 2> "/tmp/${0##*/}.$$"
			show_response $?
		fi
	else
		SHOW_LOG=$(printf '%s\n' "$URI" | grep -c ^/control/)
		# shellcheck disable=SC2086
		$CURL_CMD $UNIT_CTRL"$URI" > "/tmp/${0##*/}.$$.out" 2> "/tmp/${0##*/}.$$"
		show_response $?
	fi
else
	if [ "$METHOD" = "INSERT" ]; then
		if ! hash jq 2> /dev/null; then
			echo "${0##*/}: ERROR: jq(1) is required to use the INSERT method; install at <https://stedolan.github.io/jq/>" >&2
			exit 1
		fi
		NEW_ELEMENT=$(cat_conf_files)
		printf '%s\n' "$NEW_ELEMENT" | jq > /dev/null || exit $? # Test the input is valid JSON before proceeding
		# shellcheck disable=SC2086
		OLD_ARRAY=$($CURL_CMD -s $UNIT_CTRL"$URI")
		if [ "$(printf '%s\n' "$OLD_ARRAY" | jq -r type)" = "array" ]; then
			# shellcheck disable=SC2086
			printf '%s\n' "$OLD_ARRAY" | jq ". |= [$NEW_ELEMENT] + ." | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL"$URI" > "/tmp/${0##*/}.$$.out" 2> "/tmp/${0##*/}.$$"
			show_response $?
		else
			echo "${0##*/}: ERROR: the INSERT method expects an array" >&2
			exit 3
		fi
	else
		if [ $CONVERT -eq 1 ]; then
			cat_conf_files | $CONVERT_TO_JSON > "/tmp/${0##*/}.$$_json"
			CONF_FILES=/tmp/${0##*/}.$$_json
		fi
		# shellcheck disable=SC2086
		cat_conf_files | $CURL_CMD -X "$METHOD" --data-binary @- $UNIT_CTRL"$URI" > "/tmp/${0##*/}.$$.out" 2> "/tmp/${0##*/}.$$"
		show_response $?
	fi
fi

if [ "$CURL_STATUS" -eq 0 ]; then
	rm -f /tmp/"${0##*/}".$$* 2> /dev/null
	if [ "$OUTPUT_STATUS" -gt 128 ]; then
		exit "$OUTPUT_STATUS" # The filter died of a signal, e.g. SIGPIPE from `unitc /config | head`
	elif [ "$OUTPUT_STATUS" -ne 0 ]; then
		printf '%s: ERROR: the response is not valid JSON (the output filter exited with status %s)\n' "${0##*/}" "$OUTPUT_STATUS" >&2
		exit 5 # 1 usage, 2 socket or editor, 3 INSERT, 4 curl
	fi
	if [ "$SHOW_LOG" -gt 0 ] && [ $NOLOG -eq 0 ] && [ $QUIET -eq 0 ]; then
		printf '%s' "${0##*/}: Waiting for log..."
		sleep "$SHOW_LOG"
		echo ""
		sed -n $((LOG_LEN+1)),\$p "$ERROR_LOG"
	fi
elif [ "$CURL_STATUS" -eq 22 ]; then
	echo "${0##*/}: ERROR: configuration not applied" >&2
	if [ "$METHOD" = "EDIT" ] && [ -n "$EDIT_FILENAME" ]; then
		RESTORED=1
		if [ -f "/tmp/${0##*/}.$$_js_module" ]; then
			echo "${0##*/}: NOTICE: restoring previous configuration" >&2
			# shellcheck disable=SC2086
			printf "%s" "$(cat "/tmp/${0##*/}.$$bak")" | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL"$URI" && \
			cat "/tmp/${0##*/}.$$_js_module" | $CURL_CMD -X PUT --data-binary @- $UNIT_CTRL/config/settings/js_module 2> "/tmp/${0##*/}.$$" || RESTORED=0
		fi
		printf '%s\n' "${0##*/}: NOTICE: $(ls "$EDIT_FILENAME".*) contains unapplied edits" >&2
		if [ $RESTORED -eq 1 ]; then
			rm -f "/tmp/${0##*/}.$$bak" "/tmp/${0##*/}.$$_js_module"
		else
			printf '%s\n' "${0##*/}: ERROR: restore failed; the previous module is in /tmp/${0##*/}.$$bak and the settings in /tmp/${0##*/}.$$_js_module" >&2
		fi
		rm -f "$EDIT_FILENAME"
	fi
	rm -f "/tmp/${0##*/}.$$"
	exit 4
else
	echo "${0##*/}: ERROR: curl(1) exited with an error ($CURL_STATUS)" >&2
	if [ "$CURL_STATUS" -eq 7 ] && [ $REMOTE -eq 0 ]; then
		echo "${0##*/}: Check that you have permission to access the Unit control socket, or try again with sudo(8)" >&2
		rm -f "/tmp/${0##*/}.$$"
	else
		printf '%s\n' "${0##*/}: Trying to access $UNIT_CTRL$URI" >&2
		cat "/tmp/${0##*/}.$$" && rm -f "/tmp/${0##*/}.$$"
	fi
	exit 4
fi
