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