{ title: "Test nginx Configuration Directives" blurb: "We use MoonScript and some Lua packages to write tests for the directives in our `nginx` configuration files." } $index ## Introduction [`nginx`](https://docs.nginx.com/nginx/admin-guide/web-server/web-server/#rewrite-uris-in-requests) configuration can contain any number of important directives (redirects and rewrites, for example) that need to be verified for correctness. We can write tests for directives and run them against a test server to ensure they are correct. To do this, we'll use... - [MoonScript](https://moonscript.org) and (by extension) [Lua](https://www.lua.org/) programming languages - `nginx` we'll get from [OpenResty](https://openresty.org/en/), a web platform created by Chinese developer, [Yichun Zhang](https://agentzh.org/) - the [Busted testing framework](https://lunarmodules.github.io/busted/) - the Lua package manager, [LuaRocks](https://luarocks.org/) - a fantastic little library, [`luajit-curl`](https://bitbucket.org/senanetworksinc/luajit-curl/src/master/), from Japanese developer [SENA Networks, Inc](https://www.sena-networks.co.jp) - another great library, written by volunteers, [LuaSocket](https://github.com/lunarmodules/luasocket) - our favorite container manager, [Docker Engine](https://docs.docker.com/engine/) ## Setup Since we require LuaRocks, we'll use a Buildpack tag, which comes with it already installed. ```console $ docker pull openresty/openresty:bookworm-buildpack ``` Start a server on `localhost`: ```console $ docker run --rm -it -p 80:80 openresty/openresty:bookworm-buildpack ``` We can visit `localhost` in our browser and we should see the OpenResty splash page. ![OpenResty default nginx index page](/images/openresty-default-index-page.png) ## Get `nginx` running First, let's [prepare the directory layout](https://openresty.org/en/getting-started.html#prepare-directory-layout). ```console $ mkdir -p logs/ conf/conf.d/ html/ ``` Next, we copy over [the default `nginx` config file](https://github.com/openresty/docker-openresty?tab=readme-ov-file#nginx-config-files). ```console $ docker run --rm -it -w /opt -v $PWD:/opt openresty/openresty:bookworm-buildpack \ cp /etc/nginx/conf.d/default.conf /opt/conf.d/ ``` Then, we update the root directive in `default.conf`: ::: filename-for-code-block `conf/conf.d/default.conf` ::: ```diff location / { - root /usr/local/openresty/nginx/html; + root /var/www; index index.html index.htm; ``` Now, let's add an index file. ::: filename-for-code-block `html/index.html` ::: ```html hello world! ``` Last, we start `nginx`: ```console $ docker run --rm -it -p 80:80 \ -v $PWD/conf/conf.d:/etc/nginx/conf.d -v $PWD/html:/var/www \ openresty/openresty:bookworm-buildpack ``` Then, in another console, this should output our index file. ```console $ curl -v localhost * Trying 127.0.0.1:80... * Connected to localhost (127.0.0.1) port 80 (#0) > GET / HTTP/1.1 > Host: localhost > User-Agent: curl/7.88.1 > Accept: */* > < HTTP/1.1 200 OK < Server: openresty/1.27.1.2 < ... < ... hello world! ``` ## Test an HTTP request If we want to write a test for that request, we need some packages from LuaRocks. Let's add a `Dockerfile` to build an image with those packages installed. ### Add a `Dockerfile` ```Dockerfile FROM openresty/openresty:bookworm-buildpack WORKDIR /opt/app RUN luarocks install moonscript RUN luarocks install busted RUN luarocks install luajit-curl RUN luarocks install luasocket ``` Now let's build our image: ```console $ docker build -t test-nginx . ``` ### Write the test Let's first make a new directory where our tests will live. ```console $ mkdir spec ``` Our test makes a cURL request against our test server: ::: filename-for-code-block `spec/nginx_spec.moon` ::: ```moonscript http = require "luajit-curl-helper.http" req = (url) -> request = http.init url st = request\perform! error request\lastError! if not st request describe "http://localhost", -> it "sends /index.html", -> request = req "http://localhost" assert.same request\statusCode!, 200 assert.same request\statusMessage!, "OK" assert.same request\body!\match("%s+(.-)%s+"), "hello world!" ``` ### Run the test suite Start the test server. We're going to use `text-nginx`, the image we just built. ```console $ ct=$(docker run --rm -d \ -v $PWD/conf/conf.d:/etc/nginx/conf.d \ -v $PWD/html:/var/www \ -v $PWD:/opt/app \ test-nginx) ``` Start the test run: ```console $ docker exec -t $ct busted ● 1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds ``` Stop the test server. ```console $ docker exec $ct openresty -s stop ``` ## Create a `Makefile` We now have a number of long `docker` commands, let's create a `Makefile` to make running them easier. ::: filename-for-code-block `Makefile` ::: ```Makefile image = test-nginx image-build: docker build -t $(image) . image-rm: docker image rm $(image) test: @ct=$(shell docker run --rm -d \ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \ -v $(PWD)/html:/var/www \ -v $(PWD):/opt/app \ $(image)); \ docker exec -t $$ct busted; \ docker exec $$ct openresty -s stop ``` Now we can run the test suite with the command `make test`. ## Configure the domain name Instead of `localhost` we'd like to use an actual domain name. We can do this with the `--add-host` option. But before we do that, we want to make sure our container does not have access to the internet, otherwise we might unintentionally get a response from a domain's server on the internet rather than from our test server. ### Ensure the test container is offline We need to create a network that has no external access. ```console $ docker network create --internal no-internet ``` Now we need to update our `Makefile` to add the test container to our internal-only network: ```diff test: @ct=$(shell docker run --rm -d \ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \ -v $(PWD)/html:/var/www \ -v $(PWD):/opt/app \ + --network no-internet \ $(image)); \ ``` And now let's add a test in `spec/nginx_spec.moon` to make sure our test environment is offline: ```moonscript describe "test environment", -> it "can't connect to the internet", -> assert.has_error (-> req "http://example.org"), "Couldn't resolve host name" ``` Let's run our tests: ```console $ make test ●● 2 successes / 0 failures / 0 errors / 0 pending : 0.020207 seconds ``` ### Replace `localhost` with a custom domain To use a custom domain name instead of `localhost`, we will need to use the `--add-host` option for the `docker run` command. Again, we edit `Makefile`: ```diff test: @ct=$(shell docker run --rm -d \ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \ -v $(PWD)/html:/var/www \ -v $(PWD):/opt/app \ --network no-internet \ + --add-host=domain.abc=127.0.0.1 \ $(image)); \ ``` Let's update our test to use the custom domain name: ::: filename-for-code-block `spec/nginx_spec.moon` ::: ```diff -describe "http://localhost", -> +describe "http://domain.abc", -> it "sends /index.html", -> - request = req "http://localhost" + request = req "http://domain.abc" assert.same request\statusCode!, 200 ``` Verify our tests still pass. ```console $ make test ●● 2 successes / 0 failures / 0 errors / 0 pending : 0.0224 seconds ``` ## Test an HTTP redirect We want our server to redirect all `http` requests to `https`. ### Write the test Let's practice a bit of test-driven development and write our test first. ```moonscript describe "http://domain.abc", -> it "redirects to https", -> request = req "http://domain.abc" assert.same request\statusCode!, 301 assert.same request\statusMessage!, "Moved Permanently" assert.same request\header!.Location, "https://domain.abc/" ``` We should now have one failing test. ```console $ make test ●●◼ 2 successes / 1 failure / 0 errors / 0 pending : 0.010449 seconds Failure → .../luajit/lib/luarocks/rocks-5.1/busted/2.2.0-1/bin/busted @ 3 http://domain.abc redirects to https spec/nginx_spec.moon:24: Expected objects to be the same. Passed in: (number) 301 Expected: (number) 200 ``` ### Configure `nginx` We're going to add the redirect directives, as well as a server name for our domain and the directives for the SSL certificates we will generate. ```diff +server { + listen 80; + return 301 https://$host$request_uri; +} server { - listen 80; + listen 443 ssl; + server_name domain.abc; + ssl_certificate /etc/ssl/certs/domain.abc.pem; + ssl_certificate_key /etc/ssl/private/domain.abc.pem; location / { root /var/www; index index.html index.htm; } ``` ### Generate self-signed SSL/TLS certs for testing Add a command to our `Dockerfile` to generate self-signed certificates: ```Dockerfile RUN openssl req -x509 -newkey rsa:4096 -nodes \ -keyout /etc/ssl/private/domain.abc.pem \ -out /etc/ssl/certs/domain.abc.pem \ -sha256 -days 365 -subj '/CN=domain.abc' \ -addext "subjectAltName=DNS:domain.abc" ``` Rebuild the image: ```console $ make image-rm image-build ``` We need to update our previous test to use HTTPS instead of HTTP. ::: filename-for-code-block `spec/nginx_spec.moon` ::: ```diff -describe "http://domain.abc", -> +describe "https://domain.abc", -> it "sends /index.html", -> - request = req "http://domain.abc" + request = req "https://domain.abc" ``` Run tests: ```console $ make test ●●● 3 successes / 0 failures / 0 errors / 0 pending : 0.017065 seconds ``` ## Test reverse proxy a subdomain request to a Unix socket Let's say we have a running service that connects to a Unix socket. We want to proxy the requests through `nginx` so that our service can respond to `https` requests but can leave handling SSL/TLS to `nginx`. ### Configure `nginx` We'll add another server block to `conf/conf.d/default.conf` for our subdomain, `git.domain.abc`, with the proxy directives: ```nginx server { listen 443 ssl; server_name git.domain.abc; location / { client_max_body_size 1024M; proxy_pass http://unix:/run/gitea/gitea.socket; proxy_set_header Connection $http_connection; proxy_set_header Upgrade $http_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` ### Add subdomain to SSL/TLS certs Next, we need to add our subdomain to the generated SSL certs in the `Dockerfile`: ```diff RUN openssl req -x509 -newkey rsa:4096 -nodes \ -keyout /etc/ssl/private/domain.abc.pem \ -out /etc/ssl/certs/domain.abc.pem \ -sha256 -days 365 -subj '/CN=domain.abc' \ - -addext "subjectAltName=DNS:domain.abc" + -addext "subjectAltName=DNS:domain.abc,DNS:git.domain.abc" ``` ### Add subdomain as a host Let's assign the loopback address to a variable and then add our subdomain as a host in our `Makefile`: ```diff +loopback = 127.0.0.1 test: @ct=$(shell docker run --rm -d \ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \ -v $(PWD)/html:/var/www \ -v $(PWD):/opt/app \ --network no-internet \ - --add-host=domain.abc=127.0.0.1 \ + --add-host=domain.abc=$(loopback) \ + --add-host=git.domain.abc=$(loopback) \ $(image)); \ ``` ### Add a test socket server We need to start up a mock socket server for our test to ensure our request is being proxied correctly. This is why we needed the LuaSocket library. Copied and modified from [here](https://github.com/lunarmodules/luasocket/blob/4844a48fbf76b0400fd7b7e4d15d244484019df1/test/unixstreamsrvr.lua), this should suit our purposes: ::: filename-for-code-block `spec/unixstreamsrvr.moon` ::: ```moonscript socket = require "socket" socket.unix = require "socket.unix" u = assert socket.unix.stream! assert u\bind "/run/gitea/gitea.socket" assert u\listen! assert u\settimeout 1 c = assert u\accept! while true m = assert c\receive! break if m == "" print m ``` ### Write the test And now we can add our test: ::: filename-for-code-block `spec/nginx_spec.moon` ::: ```moonscript describe "https://git.domain.abc", -> it "reverse-proxy's a subdomain request to a unix socket", -> socket = fname: "unixstreamsrvr.moon", dir: "/run/gitea", owner: "nobody" basepath = debug.getinfo(1).short_src\match"^(.*)/[^/]*$" or "." seconds = 0.1 os.execute "install -o #{socket.owner} -d #{socket.dir}" cmd = "su -s /bin/bash -c 'moon %s' %s" server = io.popen cmd\format "#{basepath}/#{socket.fname}", socket.owner os.execute "sleep #{seconds}" -- wait for server to start f = io.popen "find #{socket.dir} -type s -ls", "r" result = with f\read "*a" f\close! assert.truthy result\match "nobody%s+nogroup.+#{socket.dir}/gitea.socket" req "https://git.domain.abc" reqheader = with server\read "*a" server\close! assert.truthy reqheader\match "Host: git.domain.abc" ``` Because we modified the `Dockerfile`, we need to rebuild our image: ```console $ make image-rm image-build ``` And if all went well, our test should pass. ```console $ make test ●●●● 4 successes / 0 failures / 0 errors / 0 pending : 0.131619 seconds ``` ## Conclusion These are just a few examples of how to test `nginx` directives. Using these tools, we can verify that changes to our server configuration are working the way we intended.