paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

commit 29ea1adce4107cf95e2f0c076cac7e94f3822f69
parent 89883a45580779f5ae7f510d8e567ff056682761
Author: Christian Grothoff <grothoff@gnu.org>
Date:   Sun, 26 Apr 2026 23:18:04 +0200

test for early response

Diffstat:
Msrc/tests/README | 25+++++++++++++++++++++++++
Asrc/tests/early_response_upstream.c | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/meson.build | 14+++++++++++++-
Msrc/tests/test_reverse_proxy.sh | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 425 insertions(+), 2 deletions(-)

diff --git a/src/tests/README b/src/tests/README @@ -18,6 +18,19 @@ so that paivana is not exercised only against libmicrohttpd peers: upstream_py Python (stdlib) (pure interpreter; needs python3 at `make check` time) +A fifth, special-purpose upstream is also built: + + early_response_upstream + C / raw sockets, single-connection. Sends a 413 + response immediately after reading the request + headers, BEFORE consuming the request body — used + to exercise paivana's full-duplex forwarding path + (early upstream response while client upload is + still in flight). Writes the body byte count it + observed to a receipt file after the connection + closes, so the test can confirm paivana did not + abandon the upload mid-stream. + They all implement the same canned endpoints (see "Endpoints" below). The pipelining test client `pipeline_client` is a small C program that talks directly to the paivana listen socket using BSD sockets. @@ -89,6 +102,17 @@ Cross-cutting tests (run once): upstream down with paivana pointed at a closed port, clients receive 502 Bad Gateway with the built-in "Bad Gateway" HTML body + early upstream response against early_response_upstream, a + 768 KiB POST that the upstream answers + with a 413 BEFORE reading the body. + Verifies that (a) paivana forwards the + 413 to the client (no 502 from an + aborted forward) and (b) paivana + finishes streaming the upload — the + upstream's receipt file must record + the full 768 KiB. Exercises the + full-duplex code path in + curl_download_cb / curl_upload_cb. Environment variables --------------------- @@ -113,6 +137,7 @@ are already in use. 18402 upstream_go 18403 upstream_py 18404 upstream_rs + 18405 early_response_upstream 18499 dead port (for "upstream down" test) 18500 paivana-httpd diff --git a/src/tests/early_response_upstream.c b/src/tests/early_response_upstream.c @@ -0,0 +1,305 @@ +/* + This file is part of paivana tests. + Copyright (C) 2026 Taler Systems SA + + Paivana is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version + 3, or (at your option) any later version. + + Paivana is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty + of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See + the GNU General Public License for more details. + + You should have received a copy of the GNU General Public + License along with Paivana; see the file COPYING. If not, + write to the Free Software Foundation, Inc., 51 Franklin + Street, Fifth Floor, Boston, MA 02110-1301, USA. +*/ + +/** + * @file early_response_upstream.c + * @brief Raw-socket HTTP upstream that responds before reading the + * request body. Used to exercise paivana's full-duplex + * forwarding path: when the upstream begins responding while + * the client (paivana) is still uploading, paivana must keep + * streaming the upload to completion AND deliver the upstream + * response to its own client — neither side may be abandoned. + * + * After each connection finishes the server writes the number + * of body bytes it received (decimal, trailing newline) to the + * file given as the second argument, atomically via + * rename(2). The test driver reads that file to confirm that + * paivana actually forwarded the entire request body even + * though the upstream had already responded. + */ +#include <errno.h> +#include <netinet/in.h> +#include <signal.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/types.h> +#include <unistd.h> + + +static const char *receipt_path; +static volatile sig_atomic_t run_flag = 1; + + +static void +on_sig (int sig) +{ + (void) sig; + run_flag = 0; +} + + +/** + * Read from @a fd into @a buf until "\r\n\r\n" appears. Returns the + * total number of bytes read (headers + any body bytes carried in the + * same recv) on success, with @a *eoh set to the offset of the first + * body byte (i.e. just past the CRLFCRLF). Returns -1 on read error + * or if @a buf fills up before headers end. + */ +static ssize_t +read_until_eoh (int fd, + char *buf, + size_t cap, + size_t *eoh) +{ + size_t pos = 0; + + while (pos < cap) + { + ssize_t n = read (fd, + buf + pos, + cap - pos); + if (n <= 0) + return -1; + pos += (size_t) n; + if (pos < 4) + continue; + for (size_t i = 0; i + 3 < pos; i++) + { + if ( ('\r' == buf[i]) && + ('\n' == buf[i + 1]) && + ('\r' == buf[i + 2]) && + ('\n' == buf[i + 3]) ) + { + *eoh = i + 4; + return (ssize_t) pos; + } + } + } + return -1; +} + + +/** + * Atomically write @a count to the receipt file (write to <path>.tmp, + * fsync, rename). No-op if no receipt path was configured. + */ +static void +write_receipt (size_t count) +{ + FILE *f; + char tmp[4096]; + int rc; + + if (NULL == receipt_path) + return; + rc = snprintf (tmp, + sizeof (tmp), + "%s.tmp", + receipt_path); + if ( (rc < 0) || + ((size_t) rc >= sizeof (tmp)) ) + return; + f = fopen (tmp, "w"); + if (NULL == f) + return; + fprintf (f, "%zu\n", count); + fflush (f); + fsync (fileno (f)); + fclose (f); + if (0 != rename (tmp, receipt_path)) + unlink (tmp); +} + + +static const char EARLY_RESPONSE[] = + "HTTP/1.1 413 Payload Too Large\r\n" + "Content-Type: text/plain\r\n" + "X-Upstream: early\r\n" + "Content-Length: 23\r\n" + "\r\n" + "early-response-payload\n"; + + +/** + * Best-effort send the entire @a buf, retrying on partial writes. + * Returns 0 on success, -1 if the socket failed. + */ +static int +write_all (int fd, + const char *buf, + size_t len) +{ + size_t off = 0; + + while (off < len) + { + ssize_t w = write (fd, + buf + off, + len - off); + if (w < 0) + { + if (EINTR == errno) + continue; + return -1; + } + if (0 == w) + return -1; + off += (size_t) w; + } + return 0; +} + + +static void +handle_client (int fd) +{ + char buf[8192]; + size_t eoh = 0; + ssize_t got; + size_t body_bytes; + + got = read_until_eoh (fd, + buf, + sizeof (buf), + &eoh); + if (got < 0) + { + close (fd); + return; + } + body_bytes = (size_t) got - eoh; + + /* Send the 413 response immediately. This is the whole point + of this upstream: respond before consuming the request body. */ + if (0 != write_all (fd, + EARLY_RESPONSE, + sizeof (EARLY_RESPONSE) - 1)) + { + close (fd); + /* Receipt still useful for diagnostics: how far did we get? */ + write_receipt (body_bytes); + return; + } + + /* Now drain the rest of the request body. We must keep reading + until the client (paivana) closes its side — otherwise its + CURL would block on send() once the kernel recv buffer fills, + which would mask the very behavior we are trying to test. */ + for (;;) + { + ssize_t n = read (fd, + buf, + sizeof (buf)); + if (n < 0) + { + if (EINTR == errno) + continue; + break; + } + if (0 == n) + break; + body_bytes += (size_t) n; + } + + close (fd); + write_receipt (body_bytes); +} + + +int +main (int argc, + char **argv) +{ + int srv; + int port; + int yes = 1; + struct sockaddr_in addr; + + if (argc < 2) + { + fprintf (stderr, + "usage: %s <port> [receipt-file]\n", + argv[0]); + return 1; + } + port = atoi (argv[1]); + if (argc >= 3) + receipt_path = argv[2]; + + signal (SIGINT, on_sig); + signal (SIGTERM, on_sig); + signal (SIGPIPE, SIG_IGN); + + srv = socket (AF_INET, SOCK_STREAM, 0); + if (srv < 0) + { + perror ("socket"); + return 1; + } + setsockopt (srv, + SOL_SOCKET, + SO_REUSEADDR, + &yes, + sizeof (yes)); + memset (&addr, 0, sizeof (addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl (INADDR_LOOPBACK); + addr.sin_port = htons ((uint16_t) port); + if (0 != bind (srv, + (struct sockaddr *) &addr, + sizeof (addr))) + { + perror ("bind"); + close (srv); + return 1; + } + if (0 != listen (srv, 16)) + { + perror ("listen"); + close (srv); + return 1; + } + fprintf (stderr, + "early_response_upstream listening on port %d\n", + port); + fflush (stderr); + + while (run_flag) + { + int cfd = accept (srv, NULL, NULL); + + if (cfd < 0) + { + if (EINTR == errno) + continue; + perror ("accept"); + break; + } + /* Single-threaded: serialise connections. The reverse-proxy + test makes one request per case and Connection: close keeps + paivana from reusing the socket. */ + handle_client (cfd); + } + close (srv); + return 0; +} diff --git a/src/tests/meson.build b/src/tests/meson.build @@ -19,7 +19,19 @@ pipeline_client = executable( install: false, ) -test_deps = [upstream_mhd, pipeline_client, paivana_httpd_exe] +early_response_upstream = executable( + 'early_response_upstream', + 'early_response_upstream.c', + include_directories: [incdir, configuration_inc], + install: false, +) + +test_deps = [ + upstream_mhd, + pipeline_client, + early_response_upstream, + paivana_httpd_exe, +] go_bin = find_program('go', required: false) if go_bin.found() diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh @@ -71,6 +71,7 @@ UPSTREAM_MHD="$BUILDDIR/upstream_mhd" UPSTREAM_GO="$BUILDDIR/upstream_go" UPSTREAM_RS="$BUILDDIR/upstream_rs" PIPELINE_CLIENT="$BUILDDIR/pipeline_client" +EARLY_RESPONSE_UPSTREAM="$BUILDDIR/early_response_upstream" # Ports (fixed, but we still wait/retry binding; if they're busy the # test bails out early so the user can rerun.) @@ -79,6 +80,7 @@ MHD_PORT=18401 GO_PORT=18402 PY_PORT=18403 RS_PORT=18404 +EARLY_PORT=18405 DEAD_PORT=18499 # nothing should be listening here TMPDIR="$(mktemp -d -t paivana-tests.XXXXXX)" @@ -165,11 +167,12 @@ function start_bg() { function start_paivana() { # $1 = upstream base URL local dest="$1" + local flags="${2:-}" local cfg="$TMPDIR/paivana.conf" sed -e "s|@DEST@|$dest|g" -e "s|@PORT@|$PAIVANA_PORT|g" \ "$SRCDIR/test_reverse_proxy.conf.in" > "$cfg" local log="$LOGDIR/paivana.log" - ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING ) >"$log" 2>&1 & + ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L INFO "$flags") >"$log" 2>&1 & PAIVANA_PID=$! if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT"; then @@ -569,6 +572,81 @@ function test_upload_too_big_chunked() { ok } +function test_early_response() { + # The upstream sends a 413 response immediately after reading the + # request headers, before consuming any of the request body — a + # legal HTTP/1.1 pattern. Paivana must (a) deliver that response + # back to its client (no 502 from an aborted forward) and (b) + # still finish streaming the upload to upstream so the upstream + # sees the full request body. + # + # The byte count the upstream observed is written to RECEIPT + # after the connection finishes; we read it back to confirm. + msg "early upstream response: response forwarded AND upload completed" + if [ ! -x "$EARLY_RESPONSE_UPSTREAM" ]; + then + echo "SKIP (early_response_upstream not built)" + return + fi + stop_paivana + local receipt="$TMPDIR/early_receipt" + rm -f "$receipt" + local log="$LOGDIR/early.log" + ( exec "$EARLY_RESPONSE_UPSTREAM" "$EARLY_PORT" "$receipt" ) \ + >"$log" 2>&1 & + local early_pid=$! + PIDS+=("$early_pid") + if ! wait_for_port 127.0.0.1 "$EARLY_PORT"; + then + fail "early_response_upstream did not start on port $EARLY_PORT" + fi + start_paivana "http://127.0.0.1:$EARLY_PORT" -F + + # 768 KiB body — under paivana's 1 MiB request-buffer cap, but + # too large to fit in default kernel TCP buffers, so the response + # is guaranteed to start arriving while paivana's curl is still + # uploading. Without the full-duplex fix paivana would either + # abort the curl handle (502 to client) or silently drop the + # remaining upload bytes (receipt < expected). + local payload="$TMPDIR/early_body" + local expected=786432 + dd if=/dev/urandom of="$payload" bs=1024 count=768 status=none + local got_size + got_size="$(wc -c <"$payload" | tr -d ' ')" + [ "$got_size" = "$expected" ] || fail "test setup: payload is $got_size bytes, want $expected" + + local status + status="$(curl -sS -X POST --data-binary "@$payload" \ + --max-time 30 \ + -o "$TMPDIR/body" -w '%{http_code}' \ + "$(PAIVANA_URL /upload)" 2>"$TMPDIR/err")" \ + || fail "curl: $(cat "$TMPDIR/err")" + [ "$status" = "413" ] || \ + fail "status=$status want=413 (a 502 here means paivana aborted the forward instead of full-duplexing)" + grep -q 'early-response-payload' "$TMPDIR/body" || \ + fail "response body did not come from upstream: $(head -c 200 "$TMPDIR/body")" + + # Wait briefly for the upstream to finish draining the connection + # and write the receipt file (it does so after read() returns 0). + local tries=50 + while [ "$tries" -gt 0 ] && [ ! -s "$receipt" ]; + do + sleep 0.1 + tries=$((tries - 1)) + done + [ -s "$receipt" ] || fail "upstream did not write receipt file" + local got + got="$(tr -d '\n ' <"$receipt")" + [ "$got" = "$expected" ] || \ + fail "upstream received $got of $expected body bytes — paivana abandoned the upload mid-stream" + ok + + stop_paivana + kill -TERM "$early_pid" 2>/dev/null + wait "$early_pid" 2>/dev/null +} + + function test_upstream_down() { msg "upstream down yields 502 Bad Gateway" # Re-point paivana at a port with nothing listening. @@ -697,6 +775,9 @@ then stop_paivana fi +# --- Early-response upstream test (restarts paivana) ------------------ +test_early_response + # --- Upstream-down test (runs last because it restarts paivana) ------- test_upstream_down stop_paivana