13 KiB
{
title: "Test nginx Configuration Directives"
blurb: "Write tests for nginx.conf
directives and run them against a test
server."
}
$index
Introduction
nginx
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 and (by extension) Lua programming languages
nginx
we'll get from OpenResty, a web platform created by Chinese developer, Yichun Zhang- the Busted testing framework
- the Lua package manager, LuaRocks
- a fantastic little library,
luajit-curl
, from Japanese developer SENA Networks, Inc - another great library, written by volunteers, LuaSocket
- our favorite container manager, Docker Engine
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
Visit localhost
in browser. We should see the OpenResty splash 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 edit default.conf
to change root /usr/local/openresty/nginx/html;
to root /var/www;
:
::: filename-for-code-block
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.
::: filename-for-code-block
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
Test an HTTP request
Then, in another 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
< ...
<
<!DOCTYPE html>
<html lang="en">
...
<body>
hello world!
</body>
</html>
If we want to write a test for that, we need some packages from LuaRocks. Let's add a Dockerfile.
Add a 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:
$ docker build -t test-nginx .
Write the test
Let's first make a new directory for our 'specs'.
$ mkdir spec
Our test makes a cURL request against our test server:
::: filename-for-code-block
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:
$ 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
Ok, we now have a number of long docker
commands, let's create a Makefile
to make running them easier.
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
.
$ 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.
$ docker network create --internal no-internet
Now we can start the test server with our host:
$ 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
:::
-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.
$ docker exec -t $ct busted
●
1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds
Stop the test server.
$ docker exec -t $ct openresty -s stop
Ensure the test container is offline
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:
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/"
$ 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:
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
Run tests:
$ 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:
describe "https://domain.abc", ->
it "sends /index.html", ->
request = req "https://domain.abc"
Run tests:
$ 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
-addext "subjectAltName=DNS:domain.abc,DNS:git.domain.abc"
Add a test socket server
Copied and modified from here):
::: filename-for-code-block
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
::: filename-for-code-block
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"
Edit Makefile:
::: filename-for-code-block
Makefile
:::
--add-host=git.domain.abc=$(loopback) \
Rebuild image:
$ make image-rm image-build
Run tests:
$ 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
:::
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
:::
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:
<span>z000sitegen_markdown00dollar0000</span>.1
The string is then 'unescaped', but because the string got split by the
closing </span>
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
:::
it "escapes and unescapes double dollar signs", ->
out = flatten_html render [[
```Makefile
$$name
```]]
assert.same [[<div class="highlight"><pre><span></span><code><span class="py-w"></span><span class="py-nv">$$name</span></code></pre></div>]], out