./miti.sh

Catalin Constantin Mititiuc

Web Log

Elixir, JavaScript, SVG, Containers, Git, Linux

Questions, comments, feedback? Contact the author.

Test nginx Configuration Directives

Introduction

nginx 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…

Setup

Since we require LuaRocks, we’ll use a Buildpack tag, which comes with it already installed.

$ docker pull openresty/openresty:bookworm-buildpack

Start a server on localhost:

$ 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

Get nginx running

First, let’s prepare the directory layout.

$ mkdir -p logs/ conf/conf.d/ html/

Next, we copy over the default nginx config file.

$ 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:

conf/conf.d/default.conf

    location / {
-       root /usr/local/openresty/nginx/html;
+       root /var/www;
        index  index.html index.htm;

Now, let’s add an index file.

html/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title></title>
  </head>
  <body>
    hello world!
  </body>
</html>

Last, we start nginx:

$ 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.

$ 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
< ...
< 
<!DOCTYPE html>
<html lang="en">
  ...
  <body>
    hello world!
  </body>
</html>

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

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:

$ docker build -t test-nginx .

Write the test

Let’s first make a new directory where our tests will live.

$ mkdir spec

Our test makes a cURL request against our test server:

spec/nginx_spec.moon

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("<body>%s+(.-)%s+</body>"), "hello world!"

Run the test suite

Start the test server. We’re going to use text-nginx, the image we just built.

$ 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:

$ docker exec -t $ct busted

1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds

Stop the test server.

$ 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.

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.

$ docker network create --internal no-internet

Now we need to update our Makefile to add the test container to our internal-only network:

 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:

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:

$ 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:

 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:

spec/nginx_spec.moon

-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.

$ 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.

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.

$ 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.

+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:

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:

$ make image-rm image-build

We need to update our previous test to use HTTPS instead of HTTP.

spec/nginx_spec.moon

-describe "http://domain.abc", ->
+describe "https://domain.abc", ->
   it "sends /index.html", ->
-    request = req "http://domain.abc"
+    request = req "https://domain.abc"

Run tests:

$ 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:

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:

 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:

+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, this should suit our purposes:

spec/unixstreamsrvr.moon

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:

spec/nginx_spec.moon

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:

$ make image-rm image-build

And if all went well, our test should pass.

$ 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.