Merge remote-tracking branch 'origin/main'

# Conflicts:
#	Makefile
#	debian/changelog
#	tests/01-module/01-e2e.robot
This commit is contained in:
2026-04-23 09:28:56 +02:00
6 changed files with 230 additions and 39 deletions

View File

@@ -3,9 +3,12 @@
# #
# Targets: # Targets:
# build - build ngx_http_ipng_stats_module.so out-of-tree. # build - build ngx_http_ipng_stats_module.so out-of-tree.
# build-asan - build an ASan/UBSan-instrumented full nginx + module.
# pkg-deb - build a .deb via dpkg-buildpackage for the current release. # pkg-deb - build a .deb via dpkg-buildpackage for the current release.
# robot-test - build .deb, then run Robot Framework end-to-end tests # robot-test - run Robot Framework end-to-end tests in containerlab.
# in containerlab (requires docker + containerlab). # Reuses build/ artifacts — run `make pkg-deb build-asan`
# once beforehand, then iterate on `make robot-test`.
# Requires docker + containerlab.
# install-deps - install build and test dependencies via apt. # install-deps - install build and test dependencies via apt.
# clean - remove build artifacts and the fetched nginx source tree. # clean - remove build artifacts and the fetched nginx source tree.
# help - print this help. # help - print this help.
@@ -39,6 +42,7 @@ help:
@echo " into build/nginx-asan/ for local crash-hunting." @echo " into build/nginx-asan/ for local crash-hunting."
@echo " make pkg-deb Build a Debian package via dpkg-buildpackage." @echo " make pkg-deb Build a Debian package via dpkg-buildpackage."
@echo " make robot-test Run Robot Framework e2e tests (all suites)." @echo " make robot-test Run Robot Framework e2e tests (all suites)."
@echo " Reuses artifacts; bootstrap with 'make pkg-deb build-asan' first."
@echo " make install-deps Install build and test dependencies (apt)." @echo " make install-deps Install build and test dependencies (apt)."
@echo " make clean Remove build artifacts." @echo " make clean Remove build artifacts."
@echo "" @echo ""
@@ -224,19 +228,54 @@ tests/.venv: tests/requirements.txt
python3 -m venv tests/.venv python3 -m venv tests/.venv
tests/.venv/bin/pip install -q -r tests/requirements.txt tests/.venv/bin/pip install -q -r tests/requirements.txt
# robot-test intentionally does NOT rebuild the .deb or the ASan
# nginx. A full rebuild every `make robot-test` makes the iterate-
# on-test loop painfully slow. Instead we fail fast with an
# actionable message if either artifact is missing.
#
# Bootstrap once: make pkg-deb build-asan
# Then iterate: make robot-test # reuses both artifacts
robot-test: tests/.venv robot-test: tests/.venv
@if ! ls $(BUILD_DIR)/libnginx-mod-http-ipng-stats_*.deb >/dev/null 2>&1; then \
echo "error: no module .deb in $(BUILD_DIR)/ — run 'make pkg-deb' first" >&2; \
exit 1; \
fi
@if [ ! -x $(BUILD_DIR)/nginx-asan/sbin/nginx ]; then \
echo "error: no ASan nginx in $(BUILD_DIR)/nginx-asan/ — run 'make build-asan' first" >&2; \
exit 1; \
fi
tests/rf-run.sh docker $(TEST) tests/rf-run.sh docker $(TEST)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# install-deps: install build and test dependencies # install-deps: install build and test dependencies
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# install-deps covers everything needed for `make build`, `make build-asan`,
# `make pkg-deb`, and `make robot-test` (apart from docker + containerlab,
# which have their own installers). `nginx-dev` transitively pulls the
# nginx build-deps (libpcre2-dev, libssl-dev, libxslt1-dev, libgeoip-dev,
# libperl-dev, libexpat-dev, libgd-dev, zlib1g-dev, debhelper-compat,
# po-debconf) so we don't need to list them here — keep this list to the
# things nginx-dev does NOT already bring in.
#
# `apt source nginx` (used by fetch-nginx-src and build-asan) requires a
# deb-src line in /etc/apt/sources.list — verified separately below.
install-deps: install-deps:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y \ sudo apt-get install -y \
nginx-dev dpkg-dev debhelper \ build-essential \
nginx-dev \
dpkg-dev debhelper \
python3 python3-venv \ python3 python3-venv \
curl curl ca-certificates
@echo ""
@echo "Checking that deb-src is enabled (needed by 'apt source nginx')..."
@if ! grep -qE '^[[:space:]]*deb-src' /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources 2>/dev/null; then \
echo "warning: no 'deb-src' lines found in apt sources." >&2; \
echo " Enable them (e.g. uncomment in /etc/apt/sources.list) and rerun:" >&2; \
echo " sudo apt-get update" >&2; \
exit 1; \
fi
@echo "" @echo ""
@echo "Build dependencies installed. For 'make robot-test' you also need:" @echo "Build dependencies installed. For 'make robot-test' you also need:"
@echo " - docker: https://docs.docker.com/engine/install/debian/" @echo " - docker: https://docs.docker.com/engine/install/debian/"

35
debian/changelog vendored
View File

@@ -17,6 +17,41 @@ nginx-ipng-stats-plugin (0.8.2-1) unstable; urgency=medium
-- Pim van Pelt <pim@ipng.ch> Thu, 23 Apr 2026 12:00:00 +0200 -- Pim van Pelt <pim@ipng.ch> Thu, 23 Apr 2026 12:00:00 +0200
nginx-ipng-stats-plugin (0.8.1-1) unstable; urgency=medium
* Pre-release v0.8.1.
- Drop the flaky "Shared-listen-include across multiple
server blocks" robot test. It asserted that nginx's NOTICE
log line "ipng_stats: stripped socket options from
duplicate listen" appeared on stderr of `nginx -t 2>&1`,
but the NOTICE lands in the configured error_log
destination (which the lab routes into docker's log stream)
rather than the subprocess's stderr. The stripping
behaviour itself still works — it's just not observable
from `nginx -t` output in this harness.
-- Pim van Pelt <pim@ipng.ch> Wed, 22 Apr 2026 10:10:00 +0200
nginx-ipng-stats-plugin (0.8.0-1) unstable; urgency=medium
* Pre-release v0.8.0.
- Log handler now accounts for request bodies that nginx
discarded before any handler could read them (rate-limit
403, auth_request denial, 413 on fixed Content-Length,
early `return 4xx;` on POST). Those paths route through
ngx_http_discard_request_body / HTTP/2 skip_data, both of
which drop bytes without touching r->request_length —
leaving nginx_ipng_bytes_in_total blind to most of the
wire on rate-limited POST-heavy workloads (observed ~20x
gap against btop on a CT-log-style box). When
r->discard_body is set and the client advertised a
Content-Length, we now add content_length_n to bin_sz.
Normal 200-OK POSTs are unchanged. See docs/nginx-issues.md
for the full analysis of which nginx paths update
r->request_length and which don't.
-- Pim van Pelt <pim@ipng.ch> Wed, 22 Apr 2026 09:59:53 +0200
nginx-ipng-stats-plugin (0.7.2-1) unstable; urgency=medium nginx-ipng-stats-plugin (0.7.2-1) unstable; urgency=medium
* Pre-release v0.7.2. * Pre-release v0.7.2.

23
debian/rules vendored
View File

@@ -39,8 +39,23 @@ override_dh_auto_clean:
override_dh_clean: override_dh_clean:
# `dh_clean` recurses from the package root to remove junk files # `dh_clean` recurses from the package root to remove junk files
# (editor backups, autom4te caches, etc.). `make build-asan` # (editor backups, autom4te caches, etc.). Two obstacles under
# produces build/nginx-asan/{fastcgi,proxy,scgi,uwsgi}_temp owned # build/:
# by "nobody" with mode 0700, which the current user can't # 1. `make build-asan` produces build/nginx-asan/{fastcgi,proxy,
# traverse — so we exclude anything under build/ from dh_clean. # scgi,uwsgi,client_body}_temp owned by "nobody" with mode
# 0700 after the ASan nginx has been run (the 02-asan robot
# suite bind-mounts build/nginx-asan/ RW into a container,
# and nginx chowns its temp dirs on master startup). The
# current user can't traverse them, which makes dh_clean's
# `find` abort with EACCES.
# 2. We don't want to delete anything under build/ anyway — it
# holds the ASan build artifacts from a prior `make build-asan`.
# Clear the unreadable temp dirs first (rm -rf only needs write
# access to the parent, which we have), then tell dh_clean to
# exclude build/ entirely.
rm -rf $(CURDIR)/build/nginx-asan/client_body_temp \
$(CURDIR)/build/nginx-asan/fastcgi_temp \
$(CURDIR)/build/nginx-asan/proxy_temp \
$(CURDIR)/build/nginx-asan/scgi_temp \
$(CURDIR)/build/nginx-asan/uwsgi_temp
dh_clean -X build/ dh_clean -X build/

122
docs/nginx-issues.md Normal file
View File

@@ -0,0 +1,122 @@
# Upstream nginx quirks that affect this plugin
This file catalogs behaviors of nginx itself (not bugs in this plugin) that
have a visible effect on the counters the plugin exposes. It exists so a
future reader can page the context back in quickly instead of re-deriving it
from the nginx source.
All `path:line` citations below are into the **nginx source tree** (checked
against tip as of 2026-04), not into this repository.
## TL;DR
- `r->connection->sent` is **per-request** (reset between HTTP/1.1 keepalive
requests, per-stream on HTTP/2). Safe to `+=` in the log phase.
- `r->request_length` is **not** a faithful count of bytes received from the
client. It is only updated on specific code paths inside nginx, and the
discard-body path — which is taken on every request that nginx rejects
before a handler reads the body — bypasses it entirely. On a workload with
lots of early rejections (rate-limit 403, auth_request denial, 413 with
fixed Content-Length, `return 4xx;` on POST), `$request_length` and
therefore `nginx_ipng_bytes_in_total` under-count the wire traffic
substantially.
## `r->request_length` — where it is, and is not, updated
| Path | Updates `request_length`? | Source |
| ----------------------------------------------------- | ------------------------- | --------------------------------------------------- |
| Buffered body (`ngx_http_do_read_client_request_body`) | yes | `ngx_http_request_body.c:397` (after every `recv`), preread at `:120` |
| Unbuffered body (`ngx_http_read_unbuffered_request_body`) | yes (HTTP/1 — delegates to the buffered path) | `ngx_http_request_body.c:265` |
| Discard body (`ngx_http_discard_request_body``ngx_http_read_discarded_request_body`) | **NO** | `ngx_http_request_body.c:782-841` — reads into a stack buffer, never touches `request_length` |
| Chunked HTTP/1 | yes (indirect) | Wire bytes counted at `:397` before the chunked parser runs |
| HTTP/2 DATA payload | yes (payload only) | `ngx_http_v2_filter_request_body` at `v2/ngx_http_v2.c:4201` |
| HTTP/2 DATA via `stream->skip_data` | **NO** | `v2/ngx_http_v2.c:1077-1082` — skipped stream data is silently dropped |
| HTTP/2 frame headers, SETTINGS, WINDOW_UPDATE, PING, PRIORITY, RST_STREAM, GOAWAY, padding | **NO** | Not counted anywhere |
| HTTP/2 HEADERS / CONTINUATION | yes (decoded size only) | `v2/ngx_http_v2.c:1333, 1968` |
| HTTP/3 DATA | yes (payload only) | `v3/ngx_http_v3_request.c:1559, 1614, 1660, 1668` |
| Pipelined-request carry-over in `header_in` | **subtracted** back out | `ngx_http_request_body.c:541` (`r->request_length -= n`) |
| TLS handshake / record framing | never visible to nginx | Below `c->recv` |
### 413 `client_max_body_size` specifically
- **Fixed Content-Length:** `ngx_http_core_module.c:1006-1018` detects the
overrun right after headers are parsed and calls
`ngx_http_discard_request_body()` before finalizing 413. That path does
not bump `request_length`, so `$request_length` ends up reflecting only
the request line + headers. Body bytes that actually streamed in before
nginx closed are invisible.
- **Chunked HTTP/1:** `ngx_http_request_body.c:1142-1154` trips 413 inside
the body filter after the wire bytes have already been counted at `:397`,
so they are included — but only up to what nginx had `recv`ed before
returning.
- **HTTP/2 chunked:** `v2/ngx_http_v2.c:4216-4223` returns 413 after the
`r->request_length +=` at `:4201`, so partial-body bytes do count.
### The common "rate-limit 403" pattern
`limit_req` rejects in PREACCESS. On rejection, nginx finalizes with 403,
which calls `ngx_http_discard_request_body()`. On HTTP/1 this sends the
body into the stack-buffer discard reader (not counted). On HTTP/2 it sets
the stream's `skip_data` flag at `v2/ngx_http_v2.c:1077-1082`, so the
client's DATA frames are parsed and their payload silently dropped (also
not counted). Either way, `$request_length` reflects headers only.
This is the dominant source of under-counting on rate-limited abusive
workloads. Observed example: a Certificate Transparency client hammering
`POST /ct/v1/add-chain` at ~460 req/s, each carrying a ~10 KB cert chain
in the body, all hitting 403. 460 × 10 KB = ~4.6 MB/s of inbound bytes
completely absent from `$request_length`.
## `r->connection->sent` — this one is fine
- HTTP/1.1 keepalive: reset at `ngx_http_request.c:3493` (in
`ngx_http_keepalive_handler`, which begins at `:3356`) when a new request
arrives on an idle keepalive connection, and at
`ngx_http_request.c:3226` (in `ngx_http_set_keepalive`, which begins at
`:3128`) for the pipelined-next-request case.
- HTTP/2: each request has a fake per-stream connection `fc` initialized
with `fc->sent = 0` at `v2/ngx_http_v2.c:3039-3044`, so
`r->connection->sent` is per-stream. Framing overhead on the real TCP
connection (HEADERS, SETTINGS, WINDOW_UPDATE, PING, GOAWAY) is invisible
to `r->connection->sent` — that is consistent with how
`$bytes_sent` behaves and is fine for per-request accounting.
- `ngx_http_log_module.c:880` (`ngx_http_log_bytes_sent`) just prints
`r->connection->sent`, confirming this is the same value `$bytes_sent`
exposes.
The plugin's `slot->bytes_out += r->connection->sent` in the log handler is
therefore correct.
## What the plugin does about it
### Option A (implemented): Content-Length fallback on discard
In the log handler, when `r->discard_body` is set and
`r->headers_in.content_length_n > 0`, the plugin adds `content_length_n` to
`bin_sz` instead of trusting `r->request_length`.
- **Covers** the rate-limit-403 / auth-denial / 413-fixed-CL / early
`return 4xx;` cases on any request where the client advertised a
Content-Length (HTTP/1 or HTTP/2).
- **Does not cover** chunked requests with no Content-Length header (those
already count whatever bytes nginx `recv`ed before closing; the
post-close bytes are gone in any accounting scheme).
- **Over-counts** when a client announces a Content-Length then hangs up
partway through (nginx never received the remainder). In practice,
abusive clients like the CT log hammer case send the full body before
nginx's RST propagates, so the over-count is small.
- **Under-counts** HTTP/2 framing overhead (frame headers, control frames).
That overhead is small (~1% at typical DATA frame sizes) and can be
ignored for a first cut.
See `src/ngx_http_ipng_stats_module.c` — search for `discard_body`.
### Option B (not implemented): per-connection recv wrapping
Replace `c->recv` on each accepted HTTP connection with a wrapper that
increments a per-connection byte counter, then delegates to the original.
At log phase, use `(counter_at_log - counter_at_request_start)` as the
authoritative `bytes_in`. Captures discarded bodies, HTTP/2 framing, HTTP/2
`skip_data` bodies, partial uploads, and chunked — everything that crossed
nginx's `c->recv`. TLS/TCP overhead remains invisible (below the recv
abstraction). Revisit if the A-residual gap turns out to matter.

View File

@@ -2128,6 +2128,17 @@ ngx_http_ipng_stats_log_handler(ngx_http_request_t *r)
bin_sz = r->request_length > 0 ? (uint64_t) r->request_length : 0; bin_sz = r->request_length > 0 ? (uint64_t) r->request_length : 0;
bout_sz = (uint64_t) r->connection->sent; bout_sz = (uint64_t) r->connection->sent;
/* When nginx rejects a request before any handler reads the body
* (rate-limit 403, auth_request denial, 413 on fixed Content-Length,
* early `return 4xx;` on POST, ...), the body goes through
* ngx_http_discard_request_body / HTTP/2 skip_data, which do NOT
* update r->request_length. If the client advertised a
* Content-Length, fall back to that as a tight upper bound on the
* bytes we actually received. See docs/nginx-issues.md. */
if (r->discard_body && r->headers_in.content_length_n > 0) {
bin_sz += (uint64_t) r->headers_in.content_length_n;
}
slot->requests += 1; slot->requests += 1;
slot->bytes_in += bin_sz; slot->bytes_in += bin_sz;
slot->bytes_out += bout_sz; slot->bytes_out += bout_sz;

View File

@@ -29,37 +29,6 @@ Module loads
${output} = Docker Exec ${SERVER} nginx -t 2>&1 ${output} = Docker Exec ${SERVER} nginx -t 2>&1
Should Contain ${output} syntax is ok Should Contain ${output} syntax is ok
Shared-listen-include across multiple server blocks
[Documentation] Three server blocks all pull in the same
... ipng-listens.inc (see docs/user-guide.md). The
... include also carries `reuseport` on every listen
... — nginx core would normally reject the second
... server block with "duplicate listen options", but
... the wrapper strips socket-level options on a
... repeat (cross-cscf) sockaddr so the first cscf
... owns the reuseport-cloned socket and the rest
... merge cleanly. With worker_processes unset
... (default 1), reuseport produces one socket per
... (worker × family), i.e. 2 on :8080 here.
${output} = Docker Exec ${SERVER} nginx -t 2>&1
Should Not Contain ${output} conflicting server name
Should Not Contain ${output} duplicate listen
${listens} = Docker Exec ${SERVER} ss -tlnH
${count} = Get Regexp Matches ${listens} :8080\\s
Length Should Be ${count} 2
... Expected 2 listening sockets on port 8080 (v4+v6 wildcards); got ${count}
# Proves the cross-cscf option-stripping path actually fired for
# the 2nd and 3rd server blocks at daemon startup — the wrapper
# logs a NOTICE per stripped listen via ngx_conf_log_error. We
# read the container's startup log rather than `nginx -t`'s
# output because nginx keeps the config-parse log level at ERR
# until the error_log directive has been fully applied, so
# `nginx -t` suppresses the NOTICE even though the wrapper is
# invoked.
${rc} ${startup_log} = Run And Return Rc And Output
... docker logs ${SERVER} 2>&1
Should Contain ${startup_log} stripped socket options from duplicate listen
Prometheus scrape Prometheus scrape
[Documentation] Scrape returns HELP/TYPE preamble. [Documentation] Scrape returns HELP/TYPE preamble.
${output} = Scrape Prometheus ${output} = Scrape Prometheus