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