nginx alpine run shell script

the end goal is to run shell scripts for a given endpoint

to do this we gonna use fcgiwrap which will start dedicated process to execute our scripts

FROM nginx:alpine

RUN apk add fcgiwrap spawn-fcgi

COPY ./05-spawn-fcgi.sh /docker-entrypoint.d/05-spawn-fcgi.sh

to spawn it we adding additiona script to entrypoint

/usr/bin/spawn-fcgi -s /var/run/fcgiwrap.socket -M 766 /usr/bin/fcgiwrap

here is configuration

server {
    listen       80;
    server_name  localhost;
    root   /usr/share/nginx/html;

    location / {
        index  index.html index.htm;
    }

    # added example, any shell script in content root will be executable
    location ~ \.sh$ {
        gzip off;
        fastcgi_pass unix:/var/run/fcgiwrap.socket;
        include /etc/nginx/fastcgi_params;
    }
}

and sample script

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo 'Hello, world!'

be sure to split response body and headers by a single empty line

build

docker build -t cgi .

run

docker run -it --rm -p 8081:80 -v $PWD/demo.sh:/usr/share/nginx/html/demo.sh -v $PWD/default.conf:/etc/nginx/conf.d/default.conf cgi

home page works as usual

curl localhost:8081

and now we can run our scripts

curl http://localhost:8081/demo.sh

note: for everything to work, scripts must be executable, e.g.:

chmod +x 05-spawn-fcgi.sh
chmod +x demo.sh

environment

all request headers and query string passed to our script as environment variables, e.g. following script:

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo 'environment:'
env

and request to http://localhost:8081/demo.sh?foo=bar will print something like:

GATEWAY_INTERFACE=CGI/1.1
DOCUMENT_URI=/demo.sh
HOSTNAME=6aa74906febd
REMOTE_ADDR=172.17.0.1
QUERY_STRING=foo=bar
FCGI_ROLE=RESPONDER
DOCUMENT_ROOT=/usr/share/nginx/html
HTTP_USER_AGENT=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
HTTP_HOST=localhost:8081
REQUEST_URI=/demo.sh?foo=bar
SERVER_SOFTWARE=nginx/1.23.3
REQUEST_SCHEME=http
NGINX_VERSION=1.23.3
HTTP_ACCEPT_LANGUAGE=en-US,en;q=0.9,ru;q=0.8,uk;q=0.7
SERVER_PROTOCOL=HTTP/1.1
HTTP_ACCEPT_ENCODING=gzip, deflate, br
REDIRECT_STATUS=200
HTTP_SEC_FETCH_DEST=document
REQUEST_METHOD=GET
SERVER_ADDR=172.17.0.2
PWD=/usr/share/nginx/html
HTTP_SEC_FETCH_SITE=none
SERVER_PORT=80
SCRIPT_NAME=/demo.sh
SERVER_NAME=localhost

status

to control response status you may do something like:

#!/bin/sh

echo 'Status: 404'
echo 'Content-Type: text/plain'
echo ''
echo 'Not found'

post request body

theoretically post request body should be send to stdin but no matter what i have tried it did not work as expected and stdin is empty, even so content length header indicates that there is some body

but at the end it seems like it does not matter because it is even better and easier to pass params as custom headers, e.g.:

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo $HTTP_FOO
echo $HTTP_ACME

and usage:

curl localhost:8081/demo.sh -H 'Foo: Bar' -H 'Acme: 42'

route params

suppose we want to handle /foo/(.+)/bar

server {
    listen       80;
    server_name  localhost;
    root   /usr/share/nginx/html;

    location / {
        index  index.html index.htm;
    }

    # added example, any shell script in content root will be executable
    location ~ \.sh$ {
        gzip off;
        fastcgi_pass unix:/var/run/fcgiwrap.socket;
        include /etc/nginx/fastcgi_params;
    }


    location ~ ^/foo/(.+)/bar$ {
      gzip off;
      fastcgi_pass unix:/var/run/fcgiwrap.socket;
      include /etc/nginx/fastcgi_params;
      fastcgi_param FOOBAR $1;
      fastcgi_param SCRIPT_FILENAME  /usr/share/nginx/html/route.sh;
    }
}

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo "FOOBAR: $FOOBAR"

docker run -it --rm -p 8081:80 -v $PWD/demo.sh:/usr/share/nginx/html/demo.sh -v $PWD/route.sh:/usr/share/nginx/html/route.sh -v $PWD/route.conf:/etc/nginx/conf.d/default.conf cgi

with that in place all commands will work:

curl localhost:8081
# nginx start page
curl localhost:8081/demo.sh
# Hello, world!
curl localhost:8081/foo/helloworld/bar
# FOOBAR: helloworld

json

we can use jq to build json responses, aka

#!/bin/sh

echo 'Content-Type: application/json'
echo ''
jq -n --arg inarr "$(echo -en "hello\nworld\n")" '$inarr | split("\n")'
# echo -e "one\ntwo\nthree" | jq -R . | jq -s '.'

other samples

# jq - produce array of strings
echo -e "one\ntwo\nthree" | jq -R . | jq -s '.'
# [
#   "one",
#   "two",
#   "three"
# ]

# jq - produce json object
jq -n --arg foo "bar" --arg acme "42" '{foo: $foo, acme: $acme}'
# {
#   "foo": "bar",
#   "acme": "42"
# }

# jq - produce nested object
jq -n --arg foo "bar" --arg acme "42" '{nested: {foo: $foo, acme: $acme}, hello: "world"}'
# {
#   "nested": {
#     "foo": "bar",
#     "acme": "42"
#   },
#   "hello": "world"
# }

# jq - produce array of objects
for c in $(docker ps -aq)
do
  n=$(docker inspect $c | jq -r ".[0].Name")
  jq -n --arg c $c --arg n $n '{id: $c, name: $n}'
done | jq -n '[inputs | {id: .id, name: .name}]'
# [
#   {
#     "id": "bfca592aaa1d",
#     "name": "/goofy_heisenberg"
#   },
#   {
#     "id": "31fac6dfe385",
#     "name": "/elastic"
#   }
# ]

# jq - tsv with headers
echo -en "id\tname\n1\tone\n2\ttwo\n" | jq -s --slurp --raw-input --raw-output 'split("\n") | .[1:-1] | map(split("\t")) | map({"id": .[0], "name": .[1]})'

# jq - tsv without headers
echo -en "1\tone\n2\ttwo\n" | jq -s --slurp --raw-input --raw-output 'split("\n") | .[0:-1] | map(split("\t")) | map({"id": .[0], "name": .[1]})'

echo -en "1\tone\n2\ttwo" | jq -s --slurp --raw-input --raw-output 'split("\n")| map(split("\t")) | map({"id": .[0], "name": .[1]})'
# [
#   {
#     "id": "1",
#     "name": "one"
#   },
#   {
#     "id": "2",
#     "name": "two"
#   }
# ]

content negotiation

we may get accept header from incomming request and depending on its value do something like

if [[ $HTTP_ACCEPT == *"application/json"* ]]
then
  echo 'Content-Type: application/json'
  echo ''
  echo '{"foo":"bar"}'
else
  echo 'Content-Type: text/plain'
  echo ''
  echo 'foo: bar'
fi