{ title: "Test nginx Configuration Directives" blurb: "Write tests for `nginx.conf` directives and run them against a test server." } $index ## Introduction [`nginx`](https://docs.nginx.com/nginx/admin-guide/web-server/web-server/#rewrite-uris-in-requests) config file `nginx.conf` can contain any number of important directives (redirects and rewrites, for example) that need to be verified for correctness. We can write `specs` for directives and run them against a running test server to ensure they are correct. 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 ``` Visit `localhost` in browser. 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 edit `default.conf` to change `root /usr/local/openresty/nginx/html;` to `root /var/www;`: ::: 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 ``` ## Test an HTTP request Then, in another console: ```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! ``` If we want to write a test for that, we need some packages from LuaRocks. Let's add a Dockerfile. ### Add a `Dockerfile` ```Dockerfile FROM openresty/openresty:bookworm-buildpack WORKDIR /opt/app 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 for our 'specs'. ```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: ```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` Ok, we now have a number of long `docker` commands, let's create a `Makefile` to make running them easier. `Makefile` ```Makefile image = test-nginx loopback = 127.0.0.1 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 \ --network no-internet \ --add-host=domain.abc=$(loopback) \ $(image)); \ docker exec -t $$ct busted; \ docker exec $$ct openresty -s stop ``` Now we can run tests by running `make test`. ```console $ make test ●● 2 successes / 0 failures / 0 errors / 0 pending : 0.008812 seconds ``` ## 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. ```console $ docker network create --internal no-internet ``` Now we can start the test server with our host: ```console $ ct=$(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 \ test-nginx) ``` Update our test: ::: 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 ``` Run the tests. ```console $ docker exec -t $ct busted ● 1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds ``` Stop the test server. ```console $ docker exec -t $ct openresty -s stop ``` ### Ensure the test container 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" ``` ## Test an HTTP redirect We want our server to redirect all `http` requests to `https`. ### Write the test Our test: ```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/" ``` ```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` ``` server { listen 80; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name domain.abc; ssl_certificate /etc/ssl/certs/domain.abc.pem; ssl_certificate_key /etc/ssl/private/domain.abc.pem; ``` ### Generate self-signed SSL/TLS certs for testing Make self-signed certs in Dockerfile: ```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 ``` Run tests: ```console $ make test ●◼● 2 successes / 1 failure / 0 errors / 0 pending : 0.009618 seconds Failure → .../luajit/lib/luarocks/rocks-5.1/busted/2.2.0-1/bin/busted @ 3 http://domain.abc sends /index.html spec/nginx_spec.moon:17: Expected objects to be the same. Passed in: (number) 200 Expected: (number) 301 ``` It's our other test breaking, now. Fix spec: ```moonscript describe "https://domain.abc", -> it "sends /index.html", -> request = req "https://domain.abc" ``` Run tests: ```console $ make test ●●● 3 successes / 0 failures / 0 errors / 0 pending : 0.017065 seconds ``` 👍 ## Test subdomain reverse proxy 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` Our `nginx` config file might look something like this: ``` 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 ```Dockerfile -addext "subjectAltName=DNS:domain.abc,DNS:git.domain.abc" ``` ### Add a test socket server Copied and modified from [here](https://github.com/lunarmodules/luasocket/blob/4844a48fbf76b0400fd7b7e4d15d244484019df1/test/unixstreamsrvr.lua)): ::: 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 ::: 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" ``` Edit Makefile: ::: filename-for-code-block `Makefile` ::: --add-host=git.domain.abc=$(loopback) \ Rebuild image: ```console $ make image-rm image-build ``` Run tests: ```console $ make test ●●●● 4 successes / 0 failures / 0 errors / 0 pending : 0.131619 seconds ``` ## Conclusion Using these tools, we can verify that our `nginx` configuration is working the way we intend. ## Bonus!: Issues Ran Into Just Making This Post ### `$host$request_uri` ::: filename-for-code-block `renderers/markdown.moon` ::: ```moonscript dollar_temp = "0000sitegen_markdown00dollar0000" ``` So, when two cosmo selectors had no space between them, it was ambiguous to which selector the numbers belonged. ``` 0000sitegen_markdown00dollar0000.10000sitegen_markdown00dollar0000.2 ``` Solution was to change the first `0` to a letter `z`: ``` z000sitegen_markdown00dollar0000.1z000sitegen_markdown00dollar0000.2 ``` Because `dollar_temp` is a private attribute, I had to copy every function from the sitegen markdown renderer. Which makes my renderer a copy with some additions. ::: filename-for-code-block `spec/renderers_spec.moon` ::: ```moonscript import escape_cosmo, unescape_cosmo from require "renderers.markdown" it "escapes and unescapes adjacent cosmo selectors", -> str = "$one$two" escaped = escape_cosmo str assert.same escaped, "z000sitegen_markdown00dollar0000.1z000sitegen_markdown00dollar0000.2" assert.same str, (unescape_cosmo escape_cosmo str) ``` ### `$$ct` ``` z000sitegen_markdown00dollar0000.1 ``` Because `.` is not a valid character for a variable name, when this string is syntax-highlighted, it gets split, like this: ``` z000sitegen_markdown00dollar0000.1 ``` The string is then 'unescaped', but because the string got split by the closing `` tag, it will no longer match the pattern and the 'unescape' fails. The solution was to change the `.` in the dollar temp pattern to `_`, which is a valid character in a variable name. Update the `$one$two` spec escaped string. ::: filename-for-code-block `spec/renderers_spec.moon` ::: ```moonscript it "escapes and unescapes double dollar signs", -> out = flatten_html render [[ ```Makefile $$name ```]] assert.same [[
$$name
]], out ```