xref: /unit/tools/unitc (revision 2673:08b48a5ad55d)
1#!/bin/bash
2# unitc - a curl wrapper for configuring NGINX Unit
3# https://github.com/nginx/unit/tree/master/tools
4# NGINX, Inc. (c) 2024
5
6# Defaults
7#
8ERROR_LOG=/dev/null
9REMOTE=0
10SHOW_LOG=1
11NOLOG=0
12QUIET=0
13CONVERT=0
14URI=""
15RPC_CMD=""
16METHOD=PUT
17CONF_FILES=()
18
19while [ $# -gt 0 ]; do
20	OPTION=$(echo $1 | tr '[a-z]' '[A-Z]')
21	case $OPTION in
22		"-F" | "--FORMAT")
23			case $(echo $2 | tr '[a-z]' '[A-Z]') in
24				"YAML")
25					CONVERT=1
26					if hash yq 2> /dev/null; then
27						CONVERT_TO_JSON="yq eval -P --output-format=json"
28						CONVERT_FROM_JSON="yq eval -P --output-format=yaml"
29					else
30						echo "${0##*/}: ERROR: yq(1) is required to use YAML format; install at <https://github.com/mikefarah/yq#install>"
31						exit 1
32					fi
33					;;
34				"")
35					echo "${0##*/}: ERROR: Must specify configuration format"
36					exit 1
37					;;
38				*)
39					echo "${0##*/}: ERROR: Invalid format ($2)"
40					exit 1
41					;;
42			esac
43			shift; shift
44			;;
45
46		"-H" | "--HELP")
47			shift
48			;;
49
50		"-L" | "--NOLOG" | "--NO-LOG")
51			NOLOG=1
52			shift
53			;;
54
55		"-Q" | "--QUIET")
56			QUIET=1
57			shift
58			;;
59
60		"GET" | "PUT" | "POST" | "DELETE" | "INSERT" | "EDIT")
61			METHOD=$OPTION
62			shift
63			;;
64
65		"HEAD" | "PATCH" | "PURGE" | "OPTIONS")
66			echo "${0##*/}: ERROR: Invalid HTTP method ($OPTION)"
67			exit 1
68			;;
69
70		*)
71			if [ -f $1 ] && [ -r $1 ]; then
72				CONF_FILES+=($1)
73				if [ "${1##*.}" = "yaml" ]; then
74					echo "${0##*/}: INFO: converting $1 to JSON"
75					shift; set -- "--format" "yaml" "$@" # Apply the command line option
76				else
77					shift
78				fi
79			elif [ "${1:0:1}" = "/" ] || [ "${1:0:4}" = "http" ] && [ "$URI" = "" ]; then
80				URI=$1
81				shift
82			elif [ "${1:0:6}" = "ssh://" ]; then
83				UNIT_CTRL=$1
84				shift
85			elif [ "${1:0:9}" = "docker://" ]; then
86				UNIT_CTRL=$1
87				shift
88			else
89				echo "${0##*/}: ERROR: Invalid option ($1)"
90				exit 1
91			fi
92			;;
93	esac
94done
95
96if [ "$URI" = "" ]; then
97	cat << __EOF__
98${0##*/} - a curl wrapper for managing NGINX Unit configuration
99
100USAGE: ${0##*/} [options] URI
101
102• URI is for Unit's control API target, e.g. /config
103• A local Unit control socket is detected unless a remote one is specified.
104• Configuration data is read from stdin.
105• All options are case-insensitive (excluding filenames and URIs).
106
107General options
108  filename …          # Read configuration data from files instead of stdin
109  HTTP method         # Default=GET, or PUT when config data is present
110  EDIT                # Opens the URI contents in \$EDITOR
111  INSERT              # Virtual HTTP method; prepend data to an array
112  -f | --format YAML  # Convert configuration data to/from YAML format
113  -q | --quiet        # No output to stdout
114
115Local options
116  -l | --nolog        # Do not monitor the Unit log file after config changes
117
118Remote options
119  ssh://[user@]remote_host[:port]/path/to/control.socket  # Remote Unix socket
120  http://remote_host:port/URI                             # Remote TCP socket
121  docker://container_ID[/non-default/control.socket]      # Container on host
122
123 A remote Unit instance may also be defined with the \$UNIT_CTRL environment
124 variable as http://remote_host:port  or  ssh://…  or  docker://…  (as above).
125
126__EOF__
127	exit 1
128fi
129
130# Figure out if we're running on the Unit host, or remotely
131#
132if [ "$UNIT_CTRL" = "" ]; then
133	if [ "${URI:0:4}" = "http" ]; then
134		REMOTE=1
135		UNIT_CTRL=$(echo "$URI" | cut -f1-3 -d/)
136		URI=/$(echo "$URI" | cut -f4- -d/)
137	fi
138elif [ "${UNIT_CTRL:0:6}" = "ssh://" ]; then
139	REMOTE=1
140	RPC_CMD="ssh $(echo $UNIT_CTRL | cut -f1-3 -d/)"
141	UNIT_CTRL="--unix-socket /$(echo $UNIT_CTRL | cut -f4- -d/) _"
142elif [ "${UNIT_CTRL:0:9}" = "docker://" ]; then
143	RPC_CMD="docker exec -i $(echo $UNIT_CTRL | cut -f3 -d/)"
144	DOCKSOCK=/$(echo "$UNIT_CTRL" | cut -f4- -d/)
145	if [ "$DOCKSOCK" = "/" ]; then
146		DOCKSOCK="/var/run/control.unit.sock" # Use default location if no path
147	fi
148	UNIT_CTRL="--unix-socket $DOCKSOCK _"
149	REMOTE=1
150elif [ "${URI:0:1}" = "/" ]; then
151	REMOTE=1
152fi
153
154if [ $REMOTE -eq 0 ]; then
155	# Check if Unit is running, find the main process
156	#
157	PID=($(ps ax | grep unit:\ main | grep -v \ grep | awk '{print $1}'))
158	if [ ${#PID[@]} -eq 0 ]; then
159		echo "${0##*/}: ERROR: unitd not running (set \$UNIT_CTRL to configure a remote instance)"
160		exit 1
161	elif [ ${#PID[@]} -gt 1 ]; then
162		echo "${0##*/}: ERROR: multiple unitd processes detected (${PID[@]})"
163		exit 1
164	fi
165
166	# Read the significant unitd conifuration from cache file (or create it)
167	#
168	if [ -r /tmp/${0##*/}.$PID.env ]; then
169		source /tmp/${0##*/}.$PID.env
170	else
171		# Check we have all the tools we will need (that we didn't already use)
172		#
173		MISSING=$(hash curl tr cut sed tail sleep 2>&1 | cut -f4 -d: | tr -d '\n')
174		if [ "$MISSING" != "" ]; then
175			echo "${0##*/}: ERROR: cannot find$MISSING: please install or add to \$PATH"
176			exit 1
177		fi
178
179		# Obtain any optional startup parameters from the 'unitd: main' process
180		# so we can get the actual control address and error log location.
181		# Command line options and output of ps(1) is notoriously variable across
182		# different *nix/BSD platforms so multiple attempts might be needed.
183		#
184		PARAMS=$((ps -wwo args=COMMAND -p $PID || ps $PID) 2> /dev/null | grep unit | tr '[]' ^ | cut -f2 -d^ | sed -e 's/ --/\n--/g')
185		if [ "$PARAMS" = "" ]; then
186			echo "${0##*/}: WARNING: unable to identify unitd command line parameters for PID $PID, assuming unitd defaults from \$PATH"
187			PARAMS=unitd
188		fi
189		CTRL_ADDR=$(echo "$PARAMS" | grep '\--control ' | cut -f2 -d' ')
190		if [ "$CTRL_ADDR" = "" ]; then
191			CTRL_ADDR=$($(echo "$PARAMS") --help | grep -A1 '\--control ADDRESS' | tail -1 | cut -f2 -d\")
192		fi
193		if [ "$CTRL_ADDR" = "" ]; then
194			echo "${0##*/}: ERROR: cannot detect control socket. Did you start unitd with a relative path? Try starting unitd with --control option."
195			exit 2
196		fi
197
198		# Prepare for network or Unix socket addressing
199		#
200		if [ $(echo $CTRL_ADDR | grep -c ^unix:) -eq 1 ]; then
201			SOCK_FILE=$(echo $CTRL_ADDR | cut -f2- -d:)
202			if [ -r $SOCK_FILE ]; then
203				UNIT_CTRL="--unix-socket $SOCK_FILE _"
204			else
205				echo "${0##*/}: ERROR: cannot read unitd control socket: $SOCK_FILE"
206				ls -l $SOCK_FILE
207				exit 2
208			fi
209		else
210			UNIT_CTRL="http://$CTRL_ADDR"
211		fi
212
213		# Get error log filename
214		#
215		ERROR_LOG=$(echo "$PARAMS" | grep '\--log' | cut -f2 -d' ')
216		if [ "$ERROR_LOG" = "" ]; then
217			ERROR_LOG=$($(echo "$PARAMS") --help | grep -A1 '\--log' | tail -1 | cut -f2 -d\")
218		fi
219		if [ "$ERROR_LOG" = "" ]; then
220			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."
221			ERROR_LOG=/dev/null
222		fi
223
224		# Cache the discovery for this unit PID (and cleanup any old files)
225		#
226		rm -f /tmp/${0##*/}.* 2> /dev/null
227		echo UNIT_CTRL=\"${UNIT_CTRL}\" > /tmp/${0##*/}.$PID.env
228		echo ERROR_LOG=${ERROR_LOG} >> /tmp/${0##*/}.$PID.env
229	fi
230fi
231
232# Choose presentation style
233#
234if [ $QUIET -eq 1 ]; then
235	OUTPUT="tail -c 0" # Equivalent to >/dev/null
236elif [ $CONVERT -eq 1 ]; then
237	OUTPUT=$CONVERT_FROM_JSON
238elif hash jq 2> /dev/null; then
239	OUTPUT="jq"
240else
241	OUTPUT="cat"
242fi
243
244# Get current length of error log before we make any changes
245#
246if [ -f $ERROR_LOG ] && [ -r $ERROR_LOG ]; then
247	LOG_LEN=$(wc -l < $ERROR_LOG)
248else
249	NOLOG=1
250fi
251
252# Adjust HTTP method and curl params based on presence of stdin payload
253#
254if [ -t 0 ] && [ ${#CONF_FILES[@]} -eq 0 ]; then
255	if [ "$METHOD" = "DELETE" ]; then
256		$RPC_CMD curl -X $METHOD $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ | $OUTPUT
257	elif [ "$METHOD" = "EDIT" ]; then
258		EDITOR=$(test "$EDITOR" && printf '%s' "$EDITOR" || command -v editor || command -v vim || echo vi)
259		EDIT_FILENAME=/tmp/${0##*/}.$$${URI//\//_}
260		$RPC_CMD curl -fsS $UNIT_CTRL$URI > $EDIT_FILENAME || exit 2
261		if [ "${URI:0:12}" = "/js_modules/" ]; then
262			if ! hash jq 2> /dev/null; then
263				echo "${0##*/}: ERROR: jq(1) is required to edit JavaScript modules; install at <https://stedolan.github.io/jq/>"
264				exit 1
265			fi
266			jq -r < $EDIT_FILENAME > $EDIT_FILENAME.js # Unescape linebreaks for a better editing experience
267			EDIT_FILE=$EDIT_FILENAME.js
268			$EDITOR $EDIT_FILENAME.js || exit 2
269			# Remove the references, delete old config, push new config+reference
270			$RPC_CMD curl -fsS $UNIT_CTRL/config/settings/js_module > /tmp/${0##*/}.$$_js_module && \
271			$RPC_CMD curl -X DELETE $UNIT_CTRL/config/settings/js_module && \
272			$RPC_CMD curl -fsSX DELETE $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ && \
273			printf "%s" "$(< $EDIT_FILENAME.js)" | $RPC_CMD curl -fX PUT --data-binary @- $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ && \
274			cat /tmp/${0##*/}.$$_js_module | $RPC_CMD curl -X PUT --data-binary @- $UNIT_CTRL/config/settings/js_module 2> /tmp/${0##*/}.$$
275		elif [ $CONVERT -eq 1 ]; then
276			$CONVERT_FROM_JSON < $EDIT_FILENAME > $EDIT_FILENAME.yaml
277			$EDITOR $EDIT_FILENAME.yaml || exit 2
278			$CONVERT_TO_JSON < $EDIT_FILENAME.yaml | $RPC_CMD curl -X PUT --data-binary @- $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ | $OUTPUT
279		else
280			tr -d '\r' < $EDIT_FILENAME > $EDIT_FILENAME.json # Remove carriage-return from newlines
281			$EDITOR $EDIT_FILENAME.json || exit 2
282			cat $EDIT_FILENAME.json | $RPC_CMD curl -X PUT --data-binary @- $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ | $OUTPUT
283		fi
284	else
285		SHOW_LOG=$(echo $URI | grep -c ^/control/)
286		$RPC_CMD curl $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ | $OUTPUT
287	fi
288else
289	if [ "$METHOD" = "INSERT" ]; then
290		if ! hash jq 2> /dev/null; then
291			echo "${0##*/}: ERROR: jq(1) is required to use the INSERT method; install at <https://stedolan.github.io/jq/>"
292			exit 1
293		fi
294		NEW_ELEMENT=$(cat ${CONF_FILES[@]})
295		echo $NEW_ELEMENT | jq > /dev/null || exit $? # Test the input is valid JSON before proceeding
296		OLD_ARRAY=$($RPC_CMD curl -s $UNIT_CTRL$URI)
297		if [ "$(echo $OLD_ARRAY | jq -r type)" = "array" ]; then
298			echo $OLD_ARRAY | jq ". |= [$NEW_ELEMENT] + ." | $RPC_CMD curl -X PUT --data-binary @- $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ | $OUTPUT
299		else
300			echo "${0##*/}: ERROR: the INSERT method expects an array"
301			exit 3
302		fi
303	else
304		if [ $CONVERT -eq 1 ]; then
305			cat ${CONF_FILES[@]} | $CONVERT_TO_JSON > /tmp/${0##*/}.$$_json
306			CONF_FILES=(/tmp/${0##*/}.$$_json)
307		fi
308		cat ${CONF_FILES[@]} | $RPC_CMD curl -X $METHOD --data-binary @- $UNIT_CTRL$URI 2> /tmp/${0##*/}.$$ | $OUTPUT
309	fi
310fi
311
312CURL_STATUS=${PIPESTATUS[0]}
313if [ $CURL_STATUS -ne 0 ]; then
314	echo "${0##*/}: ERROR: curl(1) exited with an error ($CURL_STATUS)"
315	if [ $CURL_STATUS -eq 7 ] && [ $REMOTE -eq 0 ]; then
316		echo "${0##*/}: Check that you have permission to access the Unit control socket, or try again with sudo(8)"
317	else
318		echo "${0##*/}: Trying to access $UNIT_CTRL$URI"
319		cat /tmp/${0##*/}.$$ && rm -f /tmp/${0##*/}.$$
320	fi
321	exit 4
322fi
323rm -f /tmp/${0##*/}.$$* 2> /dev/null
324
325if [ $SHOW_LOG -gt 0 ] && [ $NOLOG -eq 0 ] && [ $QUIET -eq 0 ]; then
326	echo -n "${0##*/}: Waiting for log..."
327	sleep $SHOW_LOG
328	echo ""
329	sed -n $((LOG_LEN+1)),\$p $ERROR_LOG
330fi
331