599 lines
13 KiB
Markdown
599 lines
13 KiB
Markdown
{
|
|
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.
|
|
|
|

|
|
|
|
## 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
|
|
<!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`:
|
|
|
|
```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
|
|
< ...
|
|
<
|
|
<!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`
|
|
|
|
```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("<body>%s+(.-)%s+</body>"), "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:
|
|
|
|
```
|
|
<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`
|
|
:::
|
|
|
|
```moonscript
|
|
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
|
|
```
|