Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: e2e

on:
push:
branches: [master, main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
nginx: ['1.24.0', '1.25.3', '1.27.3']
steps:
- uses: actions/checkout@v4

- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential libpcre3-dev zlib1g-dev libssl-dev

- uses: actions/setup-node@v4
with:
node-version: 20

- name: Build nginx with the websockify module
id: build
run: |
NGINX_BIN="$(NGINX_VERSION=${{ matrix.nginx }} ./e2e/build-nginx.sh)"
echo "nginx_bin=${NGINX_BIN}" >> "$GITHUB_OUTPUT"

- name: Install e2e dependencies
working-directory: e2e
run: npm ci || npm install

- name: Run e2e tests
working-directory: e2e
env:
NGINX_BIN: ${{ steps.build.outputs.nginx_bin }}
run: npm test
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ Embed the [Websockify](https://github.com/kanaka/websockify/) into Nginx
make install


## Testing

A modern end-to-end test suite lives in [`e2e/`](e2e/). It builds nginx with the
module, starts a real upstream, and exercises the WebSocket endpoint with a
standards-compliant client. See [`e2e/README.md`](e2e/README.md) for details.

```
cd e2e
npm install
export NGINX_BIN="$(./build-nginx.sh)"
npm test
```


## Uasge

### Single noVNC websockify proxy
Expand Down
2 changes: 2 additions & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.nginx-build/
47 changes: 47 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# End-to-end tests

A modern, self-contained end-to-end test suite for the websockify nginx module.
It builds nginx with the module, starts a real TCP echo upstream (a stand-in for
a websockify backend such as a VNC server), and drives the public endpoint with a
standards-compliant WebSocket client ([`ws`](https://github.com/websockets/ws)).

## What is covered

- `binary` subprotocol round trips raw payloads
- `base64` subprotocol decodes client frames and re-encodes upstream data
- `binary` is preferred when the client offers both subprotocols
- large payloads stream through the proxy intact
- a connection with no supported subprotocol is rejected
- a dead upstream tears the WebSocket connection down instead of echoing data

## Running locally

You need Node.js 18+ and a C toolchain (gcc, make, PCRE/zlib/OpenSSL headers).

```bash
cd e2e
npm install

# Build nginx with the module and capture the binary path.
export NGINX_BIN="$(./build-nginx.sh)"

npm test
```

To test against an nginx you have already built with the module, skip
`build-nginx.sh` and point `NGINX_BIN` at your binary:

```bash
NGINX_BIN=/path/to/nginx npm test
```

You can choose the nginx version that `build-nginx.sh` compiles:

```bash
NGINX_VERSION=1.27.3 ./build-nginx.sh
```

## Continuous integration

`.github/workflows/e2e.yml` runs this suite on every push and pull request across
several nginx versions.
46 changes: 46 additions & 0 deletions e2e/build-nginx.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
#
# Build nginx from source with the websockify module added, so the e2e tests
# have a binary to run against. Prints the path to the built nginx binary.
#
# Usage:
# NGINX_VERSION=1.25.3 ./build-nginx.sh [build-dir]
#
set -euo pipefail

NGINX_VERSION="${NGINX_VERSION:-1.25.3}"
MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="${1:-${MODULE_DIR}/e2e/.nginx-build}"

SRC_DIR="${BUILD_DIR}/nginx-release-${NGINX_VERSION}"
PREFIX="${BUILD_DIR}/install"

mkdir -p "${BUILD_DIR}"

if [ ! -d "${SRC_DIR}" ]; then
# nginx.org is the canonical source; GitHub mirror is used as a fallback.
tarball="${BUILD_DIR}/nginx.tar.gz"
if ! curl -fSL "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" -o "${tarball}" 2>/dev/null; then
curl -fSL "https://codeload.github.com/nginx/nginx/tar.gz/refs/tags/release-${NGINX_VERSION}" -o "${tarball}"
fi
tar -xzf "${tarball}" -C "${BUILD_DIR}"
# nginx.org tarballs unpack to nginx-<ver>; normalise both layouts.
if [ -d "${BUILD_DIR}/nginx-${NGINX_VERSION}" ] && [ ! -d "${SRC_DIR}" ]; then
mv "${BUILD_DIR}/nginx-${NGINX_VERSION}" "${SRC_DIR}"
fi
fi

# The release-tag tarball ships configure under auto/, the dist tarball at root.
configure="./configure"
[ -x "${SRC_DIR}/configure" ] || configure="auto/configure"

cd "${SRC_DIR}"
"${configure}" \
--prefix="${PREFIX}" \
--add-module="${MODULE_DIR}" \
--without-http_rewrite_module \
--without-http_gzip_module >/dev/null
make -j"$(getconf _NPROCESSORS_ONLN)" >/dev/null
make install >/dev/null

echo "${PREFIX}/sbin/nginx"
173 changes: 173 additions & 0 deletions e2e/harness.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Shared end-to-end test harness.
//
// Boots a raw TCP "echo" backend (a stand-in for a real websockify upstream
// such as a VNC server) and an nginx instance built with the websockify
// module, then exposes the public WebSocket endpoint for the tests.
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

// Resolve the nginx binary. CI/local callers can override with NGINX_BIN.
export const NGINX_BIN = process.env.NGINX_BIN || 'nginx';

// Find a free TCP port by binding to port 0 and reading the assigned port.
export function freePort() {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.unref();
srv.on('error', reject);
srv.listen(0, '127.0.0.1', () => {
const { port } = srv.address();
srv.close(() => resolve(port));
});
});
}

function waitForPort(port, host = '127.0.0.1', timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tryConnect = () => {
const sock = net.connect(port, host);
sock.once('connect', () => {
sock.destroy();
resolve();
});
sock.once('error', () => {
sock.destroy();
if (Date.now() > deadline) {
reject(new Error(`timed out waiting for ${host}:${port}`));
} else {
setTimeout(tryConnect, 50);
}
});
};
tryConnect();
});
}

// A TCP echo server that simply mirrors back every byte it receives.
export function startEchoBackend(port) {
return new Promise((resolve, reject) => {
const server = net.createServer((socket) => {
socket.on('data', (chunk) => socket.write(chunk));
socket.on('error', () => {});
});
server.on('error', reject);
server.listen(port, '127.0.0.1', () => resolve(server));
});
}

// Launch an nginx instance with a single `location /websockify` that proxies
// to the given backend port. Returns a handle with `port` and `stop()`.
export async function startNginx({ backendPort, listenPort, extraLocationConfig = '' }) {
const prefix = mkdtempSync(path.join(tmpdir(), 'websockify-e2e-'));
mkdirSync(path.join(prefix, 'logs'), { recursive: true });
mkdirSync(path.join(prefix, 'temp'), { recursive: true });

const conf = `
daemon off;
worker_processes 1;
pid ${prefix}/nginx.pid;
error_log ${prefix}/logs/error.log debug;

events {
worker_connections 1024;
}

http {
access_log off;
client_body_temp_path ${prefix}/temp/client_body;
proxy_temp_path ${prefix}/temp/proxy;
fastcgi_temp_path ${prefix}/temp/fastcgi;
uwsgi_temp_path ${prefix}/temp/uwsgi;
scgi_temp_path ${prefix}/temp/scgi;

server {
listen 127.0.0.1:${listenPort};

location /websockify {
websockify_pass 127.0.0.1:${backendPort};
${extraLocationConfig}
}
}
}
`;
const confPath = path.join(prefix, 'nginx.conf');
writeFileSync(confPath, conf);

// Validate config up-front for a clearer failure message.
const check = spawnSync(NGINX_BIN, ['-t', '-p', prefix, '-c', confPath], {
encoding: 'utf8',
});
if (check.status !== 0) {
rmSync(prefix, { recursive: true, force: true });
throw new Error(`nginx config test failed:\n${check.stderr || check.stdout}`);
}

const proc = spawn(NGINX_BIN, ['-p', prefix, '-c', confPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
proc.stderr.on('data', (d) => {
stderr += d.toString();
});

const stop = () =>
new Promise((resolve) => {
proc.once('exit', () => {
rmSync(prefix, { recursive: true, force: true });
resolve();
});
proc.kill('SIGTERM');
});

try {
await waitForPort(listenPort);
} catch (err) {
await stop();
throw new Error(`${err.message}\nnginx stderr:\n${stderr}`);
}

return { port: listenPort, prefix, stop };
}

// Bring up nginx pointing at a port with no listener, to simulate an
// unreachable upstream (for example a VNC host that is down).
export async function startStackWithDeadBackend(opts = {}) {
const backendPort = await freePort(); // nothing ever listens here
const listenPort = await freePort();
const nginx = await startNginx({ backendPort, listenPort, ...opts });
return {
url: `ws://127.0.0.1:${listenPort}/websockify`,
backendPort,
listenPort,
async stop() {
await nginx.stop();
},
};
}

// Convenience: bring up backend + nginx together and return both handles.
export async function startStack(opts = {}) {
const backendPort = await freePort();
const listenPort = await freePort();
const backend = await startEchoBackend(backendPort);
let nginx;
try {
nginx = await startNginx({ backendPort, listenPort, ...opts });
} catch (err) {
backend.close();
throw err;
}
return {
url: `ws://127.0.0.1:${listenPort}/websockify`,
backendPort,
listenPort,
async stop() {
await nginx.stop();
await new Promise((resolve) => backend.close(resolve));
},
};
}
39 changes: 39 additions & 0 deletions e2e/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "websockify-nginx-module-e2e",
"version": "1.0.0",
"private": true,
"description": "Modern end-to-end tests for the websockify nginx module",
"type": "module",
"scripts": {
"test": "node --test 'test/**/*.test.mjs'"
},
"dependencies": {
"ws": "^8.18.0"
},
"engines": {
"node": ">=18"
}
}
Loading
Loading