commit b3a51664bef1f27d33a67ff8ff809c895175734b
parent b4e1c8b1d4fde98fcc3b7f4d933e8a878a15d657
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 23 Apr 2026 22:39:00 +0200
add reverse proxy tests
Diffstat:
11 files changed, 2157 insertions(+), 1 deletion(-)
diff --git a/src/backend/meson.build b/src/backend/meson.build
@@ -11,7 +11,7 @@ paivana_httpd_SOURCES = [
'paivana_pd.c',
]
-executable(
+paivana_httpd_exe = executable(
'paivana-httpd',
paivana_httpd_SOURCES,
dependencies: [
diff --git a/src/meson.build b/src/meson.build
@@ -1,2 +1,3 @@
# This file is in the public domain
subdir('backend')
+subdir('tests')
diff --git a/src/tests/README b/src/tests/README
@@ -0,0 +1,139 @@
+paivana reverse-proxy tests
+===========================
+
+This directory contains an integration test suite for the reverse-proxy
+side of paivana-httpd. All tests run paivana-httpd with `-n`
+(paywall disabled) so no merchant backend is required: they only
+verify that the proxy correctly forwards HTTP requests and responses.
+
+What gets built
+---------------
+
+The test suite uses four diverse upstream HTTP server implementations
+so that paivana is not exercised only against libmicrohttpd peers:
+
+ upstream_mhd C / libmicrohttpd (built always)
+ upstream_go Go (net/http) (built if `go` is found)
+ upstream_rs Rust (std::net) (built if `rustc` is found)
+ upstream_py Python (stdlib) (pure interpreter; needs python3
+ at `make check` time)
+
+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.
+
+Layout of the driver
+--------------------
+
+`test_reverse_proxy.sh` is the single test program automake runs.
+For each available upstream (mhd / go / py / rs) it:
+
+ 1. starts the upstream on a fixed port,
+ 2. starts paivana-httpd -n pointed at that upstream,
+ 3. runs a battery of HTTP tests with curl, wget, and the raw-socket
+ pipelining client,
+ 4. stops paivana and moves on to the next upstream.
+
+Cross-cutting error-path tests (405, 413, 502) are also covered, and
+the final case restarts paivana pointed at a dead port to exercise
+upstream-failure handling.
+
+What each test covers
+---------------------
+
+Per-upstream battery (`run_battery`):
+
+ GET /hello happy-path GET, body proxied unchanged
+ GET /status/201 2xx response status is forwarded intact
+ GET /status/404 4xx response status is forwarded intact
+ GET /status/500 5xx response status is forwarded intact
+ HEAD /hello HEAD method: headers only, no body
+ GET /large/131072 128 KiB binary response body streams
+ through paivana intact (length check)
+ POST /echo request body is forwarded unchanged;
+ body round-trip
+ POST /upload (64 KiB) large random POST upload; upstream
+ reports the byte count it saw
+ PUT /put PUT method + body forwarding
+ PATCH /patch PATCH method + body forwarding
+ (paivana sets CUSTOMREQUEST)
+ DELETE /item/1 DELETE method, 204 No Content
+ OPTIONS /hello OPTIONS method, Allow header survives
+ the round-trip
+ GET /echo-headers paivana adds the reverse-proxy headers
+ X-Forwarded-For, X-Forwarded-Proto, Via
+ custom X-Test header arbitrary client request headers are
+ forwarded unchanged
+ X-Upstream response header upstream response headers survive the
+ round-trip back to the client
+
+Cross-cutting tests (run once):
+
+ TRACE method unsupported HTTP verb yields 405 Method
+ Not Allowed (paivana rejects it, the
+ upstream is never contacted)
+ 2 MiB POST upload request bodies above the 1 MiB
+ REQUEST_BUFFER_MAX are rejected with
+ 413 Content Too Large
+ curl keep-alive x3 three GETs over one keep-alive TCP
+ connection all succeed
+ wget /hello third-party client interop
+ HTTP/1.1 pipelining (x4) four requests sent back-to-back on a
+ single TCP connection *before* reading
+ any response; responses must come back
+ in the same order and with the correct
+ status codes (200, 201, 200, 404).
+ This specifically tests that paivana's
+ per-request state machine and MHD's
+ keep-alive handling cooperate correctly.
+ upstream down with paivana pointed at a closed port,
+ clients receive 502 Bad Gateway with
+ the built-in "Bad Gateway" HTML body
+
+Environment variables
+---------------------
+
+The driver script honors:
+
+ PAIVANA_HTTPD path to paivana-httpd (default: the in-tree build)
+ SRCDIR directory containing the upstream sources and the
+ conf template (default: dirname of the script)
+ BUILDDIR directory containing upstream_mhd, pipeline_client,
+ upstream_go, upstream_rs (default: $PWD)
+ KEEP_TMP=1 do not delete the scratch dir on exit
+
+Ports used
+----------
+
+All ports are in the 184xx / 185xx range to avoid collisions with
+real services. They are fixed; the suite exits early if any of them
+are already in use.
+
+ 18401 upstream_mhd
+ 18402 upstream_go
+ 18403 upstream_py
+ 18404 upstream_rs
+ 18499 dead port (for "upstream down" test)
+ 18500 paivana-httpd
+
+Endpoints (implemented by every upstream)
+-----------------------------------------
+
+ GET /hello text "Hello from <name>\n"
+ GET /status/NNN respond with status NNN and a trivial
+ text body "status NNN\n"
+ GET /large/N N bytes of 'A'..'Z' repeating
+ GET /slow/N sleep N ms, then "slept\n"
+ GET /echo-headers text listing of received request
+ headers, "Key: Value\n" per line
+ POST /echo body is echoed verbatim
+ POST /upload "Received N bytes\n"
+ PUT /put "PUT received N\n"
+ PATCH /patch "PATCH received N\n"
+ DELETE /item* 204 No Content
+ OPTIONS * 204 + Allow: GET, POST, PUT, ...
+
+Every response also carries an `X-Upstream:` header whose value
+identifies which server handled it (mhd, go, py, rs); the client
+test cases use it to confirm that responses are coming back from
+the expected backend.
diff --git a/src/tests/meson.build b/src/tests/meson.build
@@ -0,0 +1,58 @@
+# Reverse-proxy integration tests.
+#
+# Builds the C upstream (libmicrohttpd), the raw-socket pipelining
+# client, and — when the toolchains are available — Go and Rust
+# upstreams, then runs the shell-driven test driver.
+
+upstream_mhd = executable(
+ 'upstream_mhd',
+ 'upstream_mhd.c',
+ dependencies: [mhd_dep],
+ include_directories: [incdir, configuration_inc],
+ install: false,
+)
+
+pipeline_client = executable(
+ 'pipeline_client',
+ 'pipeline_client.c',
+ include_directories: [incdir, configuration_inc],
+ install: false,
+)
+
+test_deps = [upstream_mhd, pipeline_client, paivana_httpd_exe]
+
+go_bin = find_program('go', required: false)
+if go_bin.found()
+ upstream_go = custom_target(
+ 'upstream_go',
+ input: 'upstream_go.go',
+ output: 'upstream_go',
+ command: [go_bin, 'build', '-o', '@OUTPUT@', '@INPUT@'],
+ build_by_default: true,
+ )
+ test_deps += upstream_go
+endif
+
+rustc_bin = find_program('rustc', required: false)
+if rustc_bin.found()
+ upstream_rs = custom_target(
+ 'upstream_rs',
+ input: 'upstream_rs.rs',
+ output: 'upstream_rs',
+ command: [rustc_bin, '-O', '-o', '@OUTPUT@', '@INPUT@'],
+ build_by_default: true,
+ )
+ test_deps += upstream_rs
+endif
+
+test(
+ 'reverse_proxy',
+ files('test_reverse_proxy.sh'),
+ env: {
+ 'SRCDIR': meson.current_source_dir(),
+ 'BUILDDIR': meson.current_build_dir(),
+ 'PAIVANA_HTTPD': paivana_httpd_exe.full_path(),
+ },
+ depends: test_deps,
+ timeout: 120,
+)
diff --git a/src/tests/pipeline_client.c b/src/tests/pipeline_client.c
@@ -0,0 +1,391 @@
+/*
+ This file is part of Paivana.
+ 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 pipeline_client.c
+ * @brief HTTP/1.1 pipelining client: opens a single TCP connection
+ * to a host/port, sends N GET requests back-to-back *without*
+ * waiting for their responses, then reads N responses in
+ * order and prints each body (separated by "---").
+ *
+ * Used by the paivana test suite to verify that paivana correctly
+ * handles pipelined requests on a single keep-alive connection.
+ */
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netdb.h>
+#include <netinet/in.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#define BUF_GROW 8192
+
+static int
+connect_to (const char *host,
+ const char *port)
+{
+ struct addrinfo hints = { 0 };
+ struct addrinfo *res = NULL;
+ int rc;
+
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+ rc = getaddrinfo (host,
+ port,
+ &hints,
+ &res);
+ if (0 != rc)
+ {
+ fprintf (stderr,
+ "getaddrinfo(%s:%s): %s\n",
+ host,
+ port,
+ gai_strerror (rc));
+ return -1;
+ }
+ for (struct addrinfo *p = res; NULL != p; p = p->ai_next)
+ {
+ int fd = socket (p->ai_family,
+ p->ai_socktype,
+ p->ai_protocol);
+ if (fd < 0)
+ continue;
+ if (0 == connect (fd,
+ p->ai_addr,
+ p->ai_addrlen))
+ {
+ freeaddrinfo (res);
+ return fd;
+ }
+ close (fd);
+ }
+ freeaddrinfo (res);
+ fprintf (stderr,
+ "could not connect to %s:%s\n",
+ host,
+ port);
+ return -1;
+}
+
+
+static int
+write_all (int fd,
+ const char *buf,
+ size_t len)
+{
+ while (len > 0)
+ {
+ ssize_t w = write (fd,
+ buf,
+ len);
+
+ if (w < 0)
+ {
+ if (EINTR == errno)
+ continue;
+ return -1;
+ }
+ buf += w;
+ len -= (size_t) w;
+ }
+ return 0;
+}
+
+
+/**
+ * Read exactly @a want bytes from @a fd into the tail of the buffer.
+ * Grows the buffer as needed. @a have is updated.
+ */
+static int
+ensure_bytes (int fd,
+ char **buf,
+ size_t *cap,
+ size_t *have,
+ size_t want)
+{
+ while (*have < want)
+ {
+ ssize_t r;
+ if (*have + BUF_GROW > *cap)
+ {
+ size_t nc = *cap ? *cap * 2 : BUF_GROW;
+ char *nb;
+
+ while (nc < *have + BUF_GROW)
+ nc *= 2;
+ nb = realloc (*buf,
+ nc);
+ if (NULL == nb)
+ return -1;
+ *buf = nb;
+ *cap = nc;
+ }
+
+ r = read (fd,
+ *buf + *have,
+ *cap - *have);
+ if (r < 0)
+ {
+ if (EINTR == errno)
+ continue;
+ return -1;
+ }
+ if (0 == r)
+ return -1; /* EOF */
+ *have += (size_t) r;
+ }
+ return 0;
+}
+
+
+/**
+ * Find the end of the HTTP header block ("\r\n\r\n") starting at
+ * some offset in buf. Returns the absolute offset of the byte
+ * just past the final "\r\n\r\n", or (size_t) -1 if not yet present.
+ * Grows the buffer / reads more data as needed.
+ */
+static size_t
+read_headers (int fd,
+ char **buf,
+ size_t *cap,
+ size_t *have,
+ size_t start)
+{
+ while (1)
+ {
+ if (*have >= start + 4)
+ {
+ for (size_t i = start; i + 3 < *have; i++)
+ {
+ if ( ('\r' == (*buf)[i]) &&
+ ('\n' == (*buf)[i + 1]) &&
+ ('\r' == (*buf)[i + 2]) &&
+ ('\n' == (*buf)[i + 3]) )
+ return i + 4;
+ }
+ }
+ if (ensure_bytes (fd,
+ buf,
+ cap,
+ have,
+ *have + 1))
+ return (size_t) -1;
+ }
+}
+
+
+/**
+ * Extract Content-Length from a header block [start, hdr_end).
+ * Returns -1 if not found.
+ */
+static long long
+header_content_length (const char *buf,
+ size_t start,
+ size_t hdr_end)
+{
+ const char *hdrs = buf + start;
+ size_t len = hdr_end - start;
+ const char *p = hdrs;
+ const char *end = hdrs + len;
+ const char *needle = "Content-Length:";
+
+ while (p + strlen (needle) < end)
+ {
+ const char *line_end = memchr (p,
+ '\n',
+ end - p);
+
+ if (NULL == line_end)
+ break;
+ if (0 == strncasecmp (p,
+ needle,
+ strlen (needle)))
+ {
+ const char *v = p + strlen (needle);
+
+ while ( (v < line_end) &&
+ (' ' == *v ||
+ '\t' == *v) )
+ v++;
+ return strtoll (v,
+ NULL,
+ 10);
+ }
+ p = line_end + 1;
+ }
+ return -1;
+}
+
+
+/**
+ * Parse the status code out of "HTTP/1.1 <code> ..."
+ */
+static int
+header_status (const char *buf,
+ size_t start)
+{
+ const char *p = buf + start;
+ const char *sp = strchr (p,
+ ' ');
+
+ if (NULL == sp)
+ return -1;
+ return atoi (sp + 1);
+}
+
+
+int
+main (int argc, char **argv)
+{
+ const char *host = argv[1];
+ const char *port = argv[2];
+ int n_paths = argc - 3;
+ int fd;
+
+ if (argc < 4)
+ {
+ fprintf (stderr,
+ "usage: %s <host> <port> <path> [<path>...]\n",
+ argv[0]);
+ return 2;
+ }
+
+ fd = connect_to (host,
+ port);
+ if (fd < 0)
+ return 1;
+
+ /* Pipeline: send all requests back-to-back. */
+ for (int i = 0; i < n_paths; i++)
+ {
+ char req[2048];
+ const char *conn_hdr
+ = (i == n_paths - 1)
+ ? "close"
+ : "keep-alive";
+ int n = snprintf (req,
+ sizeof (req),
+ "GET %s HTTP/1.1\r\n"
+ "Host: %s:%s\r\n"
+ "User-Agent: paivana-pipeline-test\r\n"
+ "Connection: %s\r\n"
+ "\r\n",
+ argv[3 + i],
+ host,
+ port,
+ conn_hdr);
+ if ( (n < 0) ||
+ ((size_t) n >= sizeof (req)) )
+ {
+ fprintf (stderr,
+ "request too long\n");
+ close (fd);
+ return 1;
+ }
+ if (0 != write_all (fd,
+ req,
+ (size_t) n))
+ {
+ fprintf (stderr,
+ "write failed: %s\n",
+ strerror (errno));
+ close (fd);
+ return 1;
+ }
+ }
+
+ {
+ /* Now read N responses in order, using Content-Length. */
+ char *buf = NULL;
+ size_t cap = 0;
+ size_t have = 0;
+ size_t pos = 0;
+ int ret = 0;
+
+ for (int i = 0; i < n_paths; i++)
+ {
+ size_t hdr_end = read_headers (fd,
+ &buf,
+ &cap,
+ &have,
+ pos);
+ long status;
+ long long cl;
+ size_t body_end;
+
+ if ((size_t) -1 == hdr_end)
+ {
+ fprintf (stderr,
+ "failed to read headers of response #%d\n",
+ i);
+ ret = 1;
+ break;
+ }
+ status = header_status (buf,
+ pos);
+ cl = header_content_length (buf,
+ pos,
+ hdr_end);
+ if (cl < 0)
+ {
+ fprintf (stderr,
+ "response #%d has no Content-Length (got status %d); "
+ "cannot safely parse pipelined response stream\n",
+ i,
+ (int) status);
+ ret = 1;
+ break;
+ }
+
+ body_end = hdr_end + (size_t) cl;
+ if (0 != ensure_bytes (fd,
+ &buf,
+ &cap,
+ &have,
+ body_end))
+ {
+ fprintf (stderr,
+ "short body for response #%d (expected %lld bytes)\n",
+ i, cl);
+ ret = 1;
+ break;
+ }
+ printf ("--- response %d: status=%d len=%lld ---\n",
+ i,
+ (int) status,
+ cl);
+ fwrite (buf + hdr_end,
+ 1,
+ (size_t) cl,
+ stdout);
+ if ( (cl > 0) &&
+ (buf[body_end - 1] != '\n') )
+ putchar ('\n');
+ pos = body_end;
+ }
+ free (buf);
+ close (fd);
+ return ret;
+ }
+}
diff --git a/src/tests/test_reverse_proxy.conf.in b/src/tests/test_reverse_proxy.conf.in
@@ -0,0 +1,14 @@
+# Paivana configuration used by the reverse-proxy test suite.
+# The test driver rewrites DESTINATION_BASE_URL and PORT per case
+# by generating a fresh file from this template.
+
+[paivana]
+DESTINATION_BASE_URL = @DEST@
+SERVE = tcp
+PORT = @PORT@
+BASE_URL = http://localhost:@PORT@/
+
+# In -n (no-payment) mode the merchant backend is not contacted,
+# so these values are nominal but we still need them to satisfy
+# the loader when the merchant config key happens to be present.
+SECRET = paivana-test-42
diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh
@@ -0,0 +1,532 @@
+#!/bin/bash
+#
+# Reverse-proxy integration tests for paivana.
+#
+# Starts upstream HTTP servers (one per language: C/MHD, Go, Python, Rust)
+# and a paivana-httpd instance running with -n (paywall disabled), then
+# exercises the reverse-proxy behaviors with curl, wget, and a custom
+# libcurl / raw-socket pipelining client.
+#
+# Progress markers: each test prints "<description> " with no newline,
+# then "OK" on pass or "FAIL: <detail>" on failure. Failure exits
+# non-zero, which make(1) treats as a TEST FAILURE.
+#
+# Environment variables honored:
+# PAIVANA_HTTPD path to paivana-httpd binary (default: built
+# in the sibling src/backend tree)
+# SRCDIR source dir containing .py / .rs / .go sources
+# (default: directory of this script)
+# BUILDDIR directory holding upstream_mhd, upstream_go,
+# upstream_rs, pipeline_client (default: $PWD)
+# KEEP_TMP=1 keep the scratch dir and log files after exit
+#
+set -u
+
+function die() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+function msg() {
+ printf '%s ' "$*"
+}
+
+function ok() {
+ echo "OK"
+}
+
+function fail() {
+ echo "FAIL: $*"
+ dump_logs
+ exit 1
+}
+
+function here() {
+ cd -- "$(dirname -- "$0")" && pwd
+}
+
+SRCDIR="${SRCDIR:-$(here)}"
+BUILDDIR="${BUILDDIR:-$PWD}"
+
+# Default to the in-tree build path.
+PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}"
+if [ ! -x "$PAIVANA_HTTPD" ];
+then
+ # Try the source layout (e.g. when tests are run from source tree)
+ alt="$SRCDIR/../backend/paivana-httpd"
+ if [ -x "$alt" ];
+ then
+ PAIVANA_HTTPD="$alt"
+ fi
+fi
+
+if [ ! -x "$PAIVANA_HTTPD" ];
+then
+ echo "SKIP: paivana-httpd binary not found (looked at $PAIVANA_HTTPD)" >&2
+ exit 77
+fi
+
+# Binaries/commands for upstreams
+UPSTREAM_MHD="$BUILDDIR/upstream_mhd"
+UPSTREAM_GO="$BUILDDIR/upstream_go"
+UPSTREAM_RS="$BUILDDIR/upstream_rs"
+PIPELINE_CLIENT="$BUILDDIR/pipeline_client"
+
+# Ports (fixed, but we still wait/retry binding; if they're busy the
+# test bails out early so the user can rerun.)
+PAIVANA_PORT=18500
+MHD_PORT=18401
+GO_PORT=18402
+PY_PORT=18403
+RS_PORT=18404
+DEAD_PORT=18499 # nothing should be listening here
+
+TMPDIR="$(mktemp -d -t paivana-tests.XXXXXX)"
+LOGDIR="$TMPDIR/logs"
+mkdir -p "$LOGDIR"
+
+# paivana normally resolves its config.d via the install prefix.
+# Tests run against the uninstalled build tree, so point the
+# project's base-config override at an empty directory: no
+# auxiliary config snippets are needed for reverse-proxy tests.
+BASE_CONFIG_DIR="$TMPDIR/configd"
+mkdir -p "$BASE_CONFIG_DIR"
+export PAIVANA_BASE_CONFIG="$BASE_CONFIG_DIR"
+
+PIDS=()
+PAIVANA_PID=""
+
+function dump_logs() {
+ echo "-- logs in $LOGDIR --" >&2
+ for f in "$LOGDIR"/*.log; do
+ [ -e "$f" ] || continue
+ echo "==> $f <==" >&2
+ tail -n 40 "$f" >&2
+ done
+}
+
+function cleanup() {
+ set +e
+ for p in "${PIDS[@]:-}";
+ do
+ [ -n "$p" ] && kill -TERM "$p" 2>/dev/null
+ done
+ [ -n "$PAIVANA_PID" ] && kill -TERM "$PAIVANA_PID" 2>/dev/null
+ # Give them a moment to exit cleanly
+ sleep 0.2
+ for p in "${PIDS[@]:-}";
+ do
+ [ -n "$p" ] && kill -KILL "$p" 2>/dev/null
+ done
+ [ -n "$PAIVANA_PID" ] && kill -KILL "$PAIVANA_PID" 2>/dev/null
+ if [ "${KEEP_TMP:-0}" = "1" ];
+ then
+ echo "Temp files kept in $TMPDIR" >&2
+ else
+ rm -rf "$TMPDIR"
+ fi
+}
+trap cleanup EXIT
+trap 'echo "FAIL: interrupted" >&2; exit 1' INT TERM
+
+function wait_for_port() {
+ # Block until a TCP port accepts a connection (max ~5s).
+ # NOTE: must run the /dev/tcp probe in a subshell — `exec` on the
+ # parent shell with a failing redirection would terminate bash in
+ # non-interactive mode (the 2>/dev/null does not suppress that).
+ local host="$1" port="$2" tries=50
+ while [ "$tries" -gt 0 ];
+ do
+ if ( exec 7<>"/dev/tcp/$host/$port" ) 2>/dev/null;
+ then
+ return 0
+ fi
+ sleep 0.1
+ tries=$((tries - 1))
+ done
+ return 1
+}
+
+# Start a background upstream; record pid in PIDS.
+function start_bg() {
+ local name="$1" port="$2"; shift 2
+ local log="$LOGDIR/$name.log"
+ ( exec "$@" "$port" ) >"$log" 2>&1 &
+ local pid=$!
+ PIDS+=("$pid")
+ if ! wait_for_port 127.0.0.1 "$port";
+ then
+ echo "FAIL: $name did not start on port $port" >&2
+ tail -n 20 "$log" >&2
+ exit 1
+ fi
+}
+
+function start_paivana() {
+ # $1 = upstream base URL
+ local dest="$1"
+ 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 &
+ PAIVANA_PID=$!
+ if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT";
+ then
+ echo "FAIL: paivana-httpd did not start on port $PAIVANA_PORT" >&2
+ tail -n 20 "$log" >&2
+ exit 1
+ fi
+}
+
+function stop_paivana() {
+ if [ -n "$PAIVANA_PID" ];
+ then
+ kill -TERM "$PAIVANA_PID" 2>/dev/null
+ wait "$PAIVANA_PID" 2>/dev/null
+ PAIVANA_PID=""
+ fi
+}
+
+# Start all upstreams that we have binaries for.
+function start_upstreams() {
+ start_bg mhd "$MHD_PORT" "$UPSTREAM_MHD"
+ if [ -x "$UPSTREAM_GO" ];
+ then
+ start_bg go "$GO_PORT" "$UPSTREAM_GO"
+ else
+ echo "NOTE: upstream_go not built, skipping Go upstream tests" >&2
+ GO_PORT=""
+ fi
+ if [ -x "$UPSTREAM_RS" ];
+ then
+ start_bg rs "$RS_PORT" "$UPSTREAM_RS"
+ else
+ echo "NOTE: upstream_rs not built, skipping Rust upstream tests" >&2
+ RS_PORT=""
+ fi
+ if command -v python3 >/dev/null 2>&1;
+ then
+ local log="$LOGDIR/py.log"
+ ( exec python3 "$SRCDIR/upstream_py.py" "$PY_PORT" ) >"$log" 2>&1 &
+ PIDS+=("$!")
+ if ! wait_for_port 127.0.0.1 "$PY_PORT";
+ then
+ echo "FAIL: upstream_py did not start on port $PY_PORT" >&2
+ tail -n 20 "$log" >&2
+ exit 1
+ fi
+ else
+ echo "NOTE: python3 not available, skipping Python upstream tests" >&2
+ PY_PORT=""
+ fi
+}
+
+######################################################################
+# Test helpers
+######################################################################
+
+PAIVANA_URL() { echo "http://127.0.0.1:$PAIVANA_PORT$1"; }
+
+# GET via curl; verifies status code and a substring of the body.
+function test_get() {
+ local desc="$1" path="$2" want_status="$3" want_sub="$4"
+ msg "$desc"
+ local out status
+ out="$(curl -sS -o "$TMPDIR/body" -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$TMPDIR/err")" \
+ || { fail "curl: $(cat "$TMPDIR/err")"; }
+ status="$out"
+ [ "$status" = "$want_status" ] || fail "status=$status want=$want_status"
+ if [ -n "$want_sub" ] && ! grep -q -- "$want_sub" "$TMPDIR/body";
+ then
+ fail "body missing substring '$want_sub' (got: $(tr -d '\n' <"$TMPDIR/body" | head -c 120))"
+ fi
+ ok
+}
+
+# HEAD
+function test_head() {
+ local desc="$1" path="$2" want_status="$3"
+ msg "$desc"
+ local status
+ status="$(curl -sS -I -o /dev/null -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ [ "$status" = "$want_status" ] || fail "status=$status want=$want_status"
+ ok
+}
+
+# Generic method with optional body; checks status and body substring.
+function test_method() {
+ local desc="$1" method="$2" path="$3" body="$4" want_status="$5" want_sub="$6"
+ msg "$desc"
+ local args=(-sS -o "$TMPDIR/body" -w '%{http_code}' -X "$method" "$(PAIVANA_URL "$path")")
+ if [ -n "$body" ];
+ then
+ args+=(--data-binary "@$body")
+ fi
+ local status
+ status="$(curl "${args[@]}" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ [ "$status" = "$want_status" ] || \
+ fail "status=$status want=$want_status; body=$(head -c 200 "$TMPDIR/body")"
+ if [ -n "$want_sub" ] && ! grep -q -- "$want_sub" "$TMPDIR/body";
+ then
+ fail "body missing substring '$want_sub' (got: $(head -c 200 "$TMPDIR/body"))"
+ fi
+ ok
+}
+
+######################################################################
+# Test battery — runs against whichever upstream we've pointed
+# paivana at.
+######################################################################
+
+function run_battery() {
+ local label="$1"
+
+ test_get "[$label] GET /hello (basic proxy pass-through)" \
+ /hello 200 "Hello from"
+
+ test_get "[$label] GET /status/201 (2xx status forwarding)" \
+ /status/201 201 "status 201"
+
+ test_get "[$label] GET /status/404 (4xx status forwarding)" \
+ /status/404 404 "status 404"
+
+ test_get "[$label] GET /status/500 (5xx status forwarding)" \
+ /status/500 500 "status 500"
+
+ test_head "[$label] HEAD /hello" /hello 200
+
+ # Medium response body (128 KiB): exercises streaming download
+ test_get "[$label] GET /large/131072 (128 KiB response)" \
+ /large/131072 200 ""
+ local sz
+ sz="$(wc -c <"$TMPDIR/body" | tr -d ' ')"
+ msg "[$label] verify 128 KiB body length"
+ [ "$sz" = "131072" ] || fail "got $sz bytes, expected 131072"
+ ok
+
+ # POST /echo — round-trip body
+ printf 'hello-payload-%s' "$label" >"$TMPDIR/post_body"
+ test_method "[$label] POST /echo (body round-trip)" \
+ POST /echo "$TMPDIR/post_body" 200 "hello-payload-$label"
+
+ # POST /upload — byte count
+ dd if=/dev/urandom of="$TMPDIR/rnd" bs=1024 count=64 status=none
+ test_method "[$label] POST /upload (64 KiB binary upload)" \
+ POST /upload "$TMPDIR/rnd" 200 "Received 65536 bytes"
+
+ # PUT /put
+ test_method "[$label] PUT /put (PUT forwarding)" \
+ PUT /put "$TMPDIR/post_body" 200 "PUT received"
+
+ # PATCH /patch
+ test_method "[$label] PATCH /patch (PATCH forwarding)" \
+ PATCH /patch "$TMPDIR/post_body" 200 "PATCH received"
+
+ # DELETE /item/1 with empty body
+ msg "[$label] DELETE /item/1 (204 No Content)"
+ local status
+ status="$(curl -sS -X DELETE -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /item/1)" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ [ "$status" = "204" ] || fail "status=$status"
+ ok
+
+ # OPTIONS — server should echo 204 + Allow
+ msg "[$label] OPTIONS /anything (204 + Allow header)"
+ local opts
+ opts="$(curl -sS -X OPTIONS -D "$TMPDIR/hdrs" -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ [ "$opts" = "204" ] || fail "status=$opts"
+ grep -qi '^allow:' "$TMPDIR/hdrs" || fail "no Allow header returned"
+ ok
+
+ # Header propagation: X-Forwarded-For must be added by paivana.
+ msg "[$label] GET /echo-headers (X-Forwarded-For added)"
+ curl -sS -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ grep -qi '^x-forwarded-for:' "$TMPDIR/body" || \
+ fail "upstream did not see X-Forwarded-For; headers:\n$(cat "$TMPDIR/body")"
+ grep -qi '^x-forwarded-proto:' "$TMPDIR/body" || \
+ fail "upstream did not see X-Forwarded-Proto"
+ grep -qi '^via:' "$TMPDIR/body" || \
+ fail "upstream did not see Via: paivana"
+ ok
+
+ # Custom request header must be forwarded.
+ msg "[$label] custom request header X-Test is forwarded"
+ curl -sS -H 'X-Test: dingbat-42' \
+ -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ grep -qi '^x-test:.*dingbat-42' "$TMPDIR/body" || \
+ fail "upstream did not see X-Test: dingbat-42"
+ ok
+
+ # Response header passthrough: upstream sets X-Upstream.
+ msg "[$label] upstream response header X-Upstream is forwarded"
+ curl -sS -D "$TMPDIR/hdrs" -o /dev/null "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ grep -qi '^x-upstream:' "$TMPDIR/hdrs" || \
+ fail "X-Upstream header not forwarded back to client"
+ ok
+}
+
+######################################################################
+# Cross-cutting tests (do not depend on which upstream is used).
+######################################################################
+
+function test_method_not_allowed() {
+ msg "unsupported HTTP method (TRACE) yields 405"
+ local status
+ status="$(curl -sS -X TRACE -o /dev/null -w '%{http_code}' \
+ "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ [ "$status" = "405" ] || fail "status=$status want=405"
+ ok
+}
+
+function test_upload_too_big() {
+ msg "upload exceeding 1 MiB buffer yields 413"
+ dd if=/dev/zero of="$TMPDIR/big" bs=1024 count=2048 status=none
+ local status
+ status="$(curl -sS -X POST --data-binary "@$TMPDIR/big" \
+ -o /dev/null -w '%{http_code}' \
+ "$(PAIVANA_URL /upload)" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ # 413 == Content Too Large; some builds report 500 on hook close
+ [ "$status" = "413" ] || fail "status=$status want=413"
+ ok
+}
+
+function test_upstream_down() {
+ msg "upstream down yields 502 Bad Gateway"
+ # Re-point paivana at a port with nothing listening.
+ stop_paivana
+ start_paivana "http://127.0.0.1:$DEAD_PORT"
+ local status
+ status="$(curl -sS -o "$TMPDIR/body" -w '%{http_code}' \
+ --max-time 10 \
+ "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ [ "$status" = "502" ] || fail "status=$status want=502"
+ grep -qi 'bad gateway' "$TMPDIR/body" || \
+ fail "no 'Bad Gateway' in body"
+ ok
+}
+
+# curl with multiple URLs on one command line uses HTTP keep-alive
+# (not true pipelining, but exercises the same code path in paivana
+# of handling successive requests on one TCP connection).
+function test_keepalive_curl() {
+ msg "curl keep-alive: 3 sequential GETs on one connection"
+ local out
+ out="$(curl -sS --http1.1 \
+ -w '\n@status=%{http_code}\n' \
+ "$(PAIVANA_URL /hello)" \
+ "$(PAIVANA_URL /hello)" \
+ "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ local count
+ count="$(printf '%s\n' "$out" | grep -c '^Hello from')"
+ [ "$count" = "3" ] || fail "got $count Hello lines; want 3; out:\n$out"
+ ok
+}
+
+function test_wget_basic() {
+ msg "wget fetch (third-party client interop)"
+ if ! command -v wget >/dev/null 2>&1; then
+ echo "SKIP (wget missing)"
+ return
+ fi
+ local body
+ body="$(wget -qO- --timeout=5 "$(PAIVANA_URL /hello)")" \
+ || fail "wget failed"
+ echo "$body" | grep -q '^Hello from' \
+ || fail "unexpected body from wget: $body"
+ ok
+}
+
+function test_pipelined() {
+ msg "HTTP/1.1 pipelined requests (4 back-to-back on one TCP socket)"
+ if [ ! -x "$PIPELINE_CLIENT" ]; then
+ echo "SKIP (pipeline_client not built)"
+ return
+ fi
+ local out
+ out="$("$PIPELINE_CLIENT" 127.0.0.1 "$PAIVANA_PORT" \
+ /hello /status/201 /hello /status/404 2>"$TMPDIR/err")" \
+ || fail "pipeline_client: $(cat "$TMPDIR/err")"
+ printf '%s\n' "$out" >"$TMPDIR/pipeline.out"
+ local n
+ n="$(grep -c '^--- response' "$TMPDIR/pipeline.out")"
+ [ "$n" = "4" ] || fail "got $n responses, want 4; output:\n$out"
+ # Order preserved: responses must match the request sequence.
+ grep -q '^--- response 0: status=200' "$TMPDIR/pipeline.out" \
+ || fail "response 0: wrong status; out:\n$out"
+ grep -q '^--- response 1: status=201' "$TMPDIR/pipeline.out" \
+ || fail "response 1: wrong status; out:\n$out"
+ grep -q '^--- response 2: status=200' "$TMPDIR/pipeline.out" \
+ || fail "response 2: wrong status; out:\n$out"
+ grep -q '^--- response 3: status=404' "$TMPDIR/pipeline.out" \
+ || fail "response 3: wrong status; out:\n$out"
+ ok
+}
+
+######################################################################
+# Drive the tests.
+######################################################################
+
+echo "=== paivana reverse-proxy tests ==="
+echo "Temp dir: $TMPDIR"
+echo "Paivana binary: $PAIVANA_HTTPD"
+echo "Source dir: $SRCDIR"
+echo "Build dir: $BUILDDIR"
+
+start_upstreams
+
+# --- C / libmicrohttpd upstream ---------------------------------------
+start_paivana "http://127.0.0.1:$MHD_PORT"
+run_battery "mhd"
+
+test_method_not_allowed
+test_upload_too_big
+test_keepalive_curl
+test_wget_basic
+test_pipelined
+
+stop_paivana
+
+# --- Go upstream ------------------------------------------------------
+if [ -n "$GO_PORT" ];
+then
+ start_paivana "http://127.0.0.1:$GO_PORT"
+ run_battery "go"
+ test_pipelined
+ stop_paivana
+fi
+
+# --- Python upstream --------------------------------------------------
+if [ -n "$PY_PORT" ];
+then
+ start_paivana "http://127.0.0.1:$PY_PORT"
+ run_battery "py"
+ test_pipelined
+ stop_paivana
+fi
+
+# --- Rust upstream ----------------------------------------------------
+if [ -n "$RS_PORT" ];
+then
+ start_paivana "http://127.0.0.1:$RS_PORT"
+ run_battery "rs"
+ test_pipelined
+ stop_paivana
+fi
+
+# --- Upstream-down test (runs last because it restarts paivana) -------
+test_upstream_down
+stop_paivana
+
+echo "=== all tests passed ==="
+exit 0
diff --git a/src/tests/upstream_go.go b/src/tests/upstream_go.go
@@ -0,0 +1,195 @@
+/*
+ This file is part of Paivana.
+ 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.
+*/
+
+// upstream_go: Go-based upstream HTTP server used by the
+// paivana reverse-proxy tests. Implements the same small set
+// of canned endpoints as upstream_mhd.c.
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const upstreamName = "go"
+
+func setHeaders(w http.ResponseWriter) {
+ w.Header().Set("X-Upstream", upstreamName)
+}
+
+func hello(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ w.Header().Set("Content-Type", "text/plain")
+ if r.Method == http.MethodHead {
+ w.WriteHeader(200)
+ return
+ }
+ fmt.Fprintf(w, "Hello from %s\n", upstreamName)
+}
+
+func status(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ path := strings.TrimPrefix(r.URL.Path, "/status/")
+ code, err := strconv.Atoi(path)
+ if err != nil || code < 100 || code > 599 {
+ code = 500
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(code)
+ fmt.Fprintf(w, "status %d\n", code)
+}
+
+func large(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ path := strings.TrimPrefix(r.URL.Path, "/large/")
+ n, err := strconv.Atoi(path)
+ if err != nil || n < 0 {
+ n = 0
+ }
+ if n > 10*1024*1024 {
+ n = 10 * 1024 * 1024
+ }
+ buf := make([]byte, n)
+ for i := 0; i < n; i++ {
+ buf[i] = byte('A' + i%26)
+ }
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.WriteHeader(200)
+ w.Write(buf)
+}
+
+func slow(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ path := strings.TrimPrefix(r.URL.Path, "/slow/")
+ ms, err := strconv.Atoi(path)
+ if err != nil || ms < 0 {
+ ms = 0
+ }
+ if ms > 30000 {
+ ms = 30000
+ }
+ time.Sleep(time.Duration(ms) * time.Millisecond)
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(200)
+ fmt.Fprint(w, "slept\n")
+}
+
+func echo(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ w.Header().Set("Content-Type", "application/octet-stream")
+ body, _ := io.ReadAll(r.Body)
+ w.WriteHeader(200)
+ w.Write(body)
+}
+
+func upload(w http.ResponseWriter, r *http.Request, label string) {
+ setHeaders(w)
+ body, _ := io.ReadAll(r.Body)
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(200)
+ fmt.Fprintf(w, "%s %d bytes\n", label, len(body))
+}
+
+func echoHeaders(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ var keys []string
+ for k := range r.Header {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(200)
+ for _, k := range keys {
+ for _, v := range r.Header[k] {
+ fmt.Fprintf(w, "%s: %s\n", k, v)
+ }
+ }
+ // Host isn't in r.Header
+ if r.Host != "" {
+ fmt.Fprintf(w, "Host: %s\n", r.Host)
+ }
+}
+
+func deleteItem(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ w.WriteHeader(204)
+}
+
+func options(w http.ResponseWriter, r *http.Request) {
+ setHeaders(w)
+ w.Header().Set("Allow", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
+ w.WriteHeader(204)
+}
+
+func route(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodOptions {
+ options(w, r)
+ return
+ }
+ switch {
+ case r.URL.Path == "/hello":
+ hello(w, r)
+ case strings.HasPrefix(r.URL.Path, "/status/"):
+ status(w, r)
+ case strings.HasPrefix(r.URL.Path, "/large/"):
+ large(w, r)
+ case strings.HasPrefix(r.URL.Path, "/slow/"):
+ slow(w, r)
+ case r.URL.Path == "/echo" && r.Method == http.MethodPost:
+ echo(w, r)
+ case r.URL.Path == "/upload" && r.Method == http.MethodPost:
+ upload(w, r, "Received")
+ case r.URL.Path == "/put" && r.Method == http.MethodPut:
+ upload(w, r, "PUT received")
+ case r.URL.Path == "/patch" && r.Method == http.MethodPatch:
+ upload(w, r, "PATCH received")
+ case strings.HasPrefix(r.URL.Path, "/item") && r.Method == http.MethodDelete:
+ deleteItem(w, r)
+ case r.URL.Path == "/echo-headers":
+ echoHeaders(w, r)
+ default:
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(404)
+ fmt.Fprint(w, "not found\n")
+ }
+}
+
+func main() {
+ port := "8402"
+ if len(os.Args) > 1 {
+ port = os.Args[1]
+ }
+ srv := &http.Server{
+ Addr: ":" + port,
+ Handler: http.HandlerFunc(route),
+ }
+ fmt.Fprintf(os.Stderr, "upstream_go listening on port %s\n", port)
+ if err := srv.ListenAndServe(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/src/tests/upstream_mhd.c b/src/tests/upstream_mhd.c
@@ -0,0 +1,467 @@
+/*
+ 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 upstream_mhd.c
+ * @brief libmicrohttpd-based upstream test server for the
+ * paivana reverse-proxy test suite. Implements a small
+ * set of canned endpoints (see README).
+ */
+#include <errno.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/time.h>
+#include <microhttpd.h>
+
+#define SERVER_NAME "mhd"
+
+struct Ctx
+{
+ char *body;
+ size_t body_len;
+ size_t body_cap;
+};
+
+
+static void
+ctx_free (struct Ctx *c)
+{
+ free (c->body);
+ free (c);
+}
+
+
+struct HdrBuf
+{
+ char *data;
+ size_t len;
+ size_t cap;
+};
+
+
+static enum MHD_Result
+append_hdr (void *cls,
+ enum MHD_ValueKind kind,
+ const char *key,
+ const char *value)
+{
+ struct HdrBuf *hb = cls;
+ size_t need = strlen (key) + strlen (value) + 4;
+
+ (void) kind;
+ if (hb->len + need > hb->cap)
+ {
+ size_t nc = hb->cap ? hb->cap * 2 : 1024;
+ char *nb;
+
+ while (nc < hb->len + need)
+ nc *= 2;
+ nb = realloc (hb->data, nc);
+ if (NULL == nb)
+ return MHD_NO;
+ hb->data = nb;
+ hb->cap = nc;
+ }
+ hb->len += (size_t) snprintf (hb->data + hb->len,
+ hb->cap - hb->len,
+ "%s: %s\n",
+ key,
+ value);
+ return MHD_YES;
+}
+
+
+static struct MHD_Response *
+make_text_response (const char *s)
+{
+ struct MHD_Response *r;
+
+ r = MHD_create_response_from_buffer (strlen (s),
+ (void *) s,
+ MHD_RESPMEM_MUST_COPY);
+ MHD_add_response_header (r,
+ MHD_HTTP_HEADER_CONTENT_TYPE,
+ "text/plain");
+ MHD_add_response_header (r,
+ "X-Upstream",
+ SERVER_NAME);
+ return r;
+}
+
+
+static enum MHD_Result
+reply_text (struct MHD_Connection *con,
+ unsigned int status,
+ const char *s)
+{
+ struct MHD_Response *r = make_text_response (s);
+ enum MHD_Result ret;
+
+ ret = MHD_queue_response (con,
+ status,
+ r);
+ MHD_destroy_response (r);
+ return ret;
+}
+
+
+static enum MHD_Result
+reply_bytes (struct MHD_Connection *con,
+ unsigned int status,
+ const char *buf,
+ size_t len)
+{
+ struct MHD_Response *r;
+ enum MHD_Result ret;
+
+ r = MHD_create_response_from_buffer (len,
+ (void *) buf,
+ MHD_RESPMEM_MUST_COPY);
+ MHD_add_response_header (r,
+ "X-Upstream",
+ SERVER_NAME);
+ MHD_add_response_header (r,
+ MHD_HTTP_HEADER_CONTENT_TYPE,
+ "application/octet-stream");
+ ret = MHD_queue_response (con,
+ status,
+ r);
+ MHD_destroy_response (r);
+ return ret;
+}
+
+
+static enum MHD_Result
+handler (void *cls,
+ struct MHD_Connection *con,
+ const char *url,
+ const char *method,
+ const char *version,
+ const char *upload_data,
+ size_t *upload_data_size,
+ void **con_cls)
+{
+ struct Ctx *ctx = *con_cls;
+
+ (void) cls;
+ (void) version;
+ if (NULL == ctx)
+ {
+ ctx = calloc (1,
+ sizeof (*ctx));
+ *con_cls = ctx;
+ return MHD_YES;
+ }
+ if (0 != *upload_data_size)
+ {
+ if (ctx->body_len + *upload_data_size > ctx->body_cap)
+ {
+ size_t n = ctx->body_cap ? ctx->body_cap * 2 : 4096;
+ char *nb;
+
+ while (n < ctx->body_len + *upload_data_size)
+ n *= 2;
+ nb = realloc (ctx->body,
+ n);
+ if (NULL == nb)
+ return MHD_NO;
+ ctx->body = nb;
+ ctx->body_cap = n;
+ }
+ memcpy (ctx->body + ctx->body_len,
+ upload_data,
+ *upload_data_size);
+ ctx->body_len += *upload_data_size;
+ *upload_data_size = 0;
+ return MHD_YES;
+ }
+
+ /* OPTIONS on anything */
+ if (0 == strcmp (method,
+ MHD_HTTP_METHOD_OPTIONS))
+ {
+ struct MHD_Response *r = make_text_response ("");
+ enum MHD_Result ret;
+
+ MHD_add_response_header (r,
+ MHD_HTTP_HEADER_ALLOW,
+ "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS");
+ ret = MHD_queue_response (con,
+ MHD_HTTP_NO_CONTENT,
+ r);
+ MHD_destroy_response (r);
+ return ret;
+ }
+
+ /* GET /hello */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_GET) ||
+ 0 == strcmp (method,
+ MHD_HTTP_METHOD_HEAD)) &&
+ (0 == strcmp (url,
+ "/hello")) )
+ return reply_text (con,
+ MHD_HTTP_OK,
+ "Hello from " SERVER_NAME "\n");
+
+ /* GET /echo-headers — return all request headers in the body */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_GET)) &&
+ (0 == strcmp (url,
+ "/echo-headers")) )
+ {
+ struct HdrBuf hb = { 0 };
+ enum MHD_Result ret;
+
+ MHD_get_connection_values (con,
+ MHD_HEADER_KIND,
+ &append_hdr,
+ &hb);
+ ret = reply_bytes (con,
+ MHD_HTTP_OK,
+ hb.data ? hb.data : "",
+ hb.len);
+ free (hb.data);
+ return ret;
+ }
+
+ /* GET /status/NNN */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_GET)) &&
+ (0 == strncmp (url,
+ "/status/",
+ strlen ("/status/"))) )
+ {
+ char msg[64];
+ int s = atoi (url + 8);
+
+ if (s < 100 || s > 599)
+ s = 500;
+ snprintf (msg,
+ sizeof (msg),
+ "status %d\n",
+ s);
+ return reply_text (con,
+ (unsigned int) s,
+ msg);
+ }
+
+ /* GET /large/NNN */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_GET) ||
+ 0 == strcmp (method,
+ MHD_HTTP_METHOD_HEAD)) &&
+ (0 == strncmp (url,
+ "/large/",
+ strlen ("/large/"))) )
+ {
+ long n = atol (url + 7);
+ char *b;
+ enum MHD_Result ret;
+
+ if (n < 0)
+ n = 0;
+ if (n > 10 * 1024 * 1024)
+ n = 10 * 1024 * 1024;
+ b = malloc ((size_t) n);
+ if (NULL == b && n > 0)
+ return reply_text (con, 500, "oom\n");
+ for (long i = 0; i < n; i++)
+ b[i] = 'A' + (char) (i % 26);
+ ret = reply_bytes (con,
+ MHD_HTTP_OK,
+ b,
+ (size_t) n);
+ free (b);
+ return ret;
+ }
+
+ /* GET /slow/NNN — sleep NNN ms then respond */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_GET)) &&
+ (0 == strncmp (url,
+ "/slow/",
+ strlen ("/slow/"))) )
+ {
+ int ms = atoi (url + 6);
+ struct timespec ts;
+
+ if (ms < 0)
+ ms = 0;
+ if (ms > 30000)
+ ms = 30000;
+ ts.tv_sec = ms / 1000;
+ ts.tv_nsec = (ms % 1000) * 1000000L;
+ nanosleep (&ts, NULL);
+ return reply_text (con,
+ MHD_HTTP_OK,
+ "slept\n");
+ }
+
+ /* POST /echo — echoes body */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_POST)) &&
+ (0 == strcmp (url, "/echo")) )
+ return reply_bytes (con,
+ MHD_HTTP_OK,
+ ctx->body ? ctx->body : "",
+ ctx->body_len);
+
+ /* POST /upload — returns count */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_POST)) &&
+ (0 == strcmp (url,
+ "/upload")) )
+ {
+ char msg[64];
+
+ snprintf (msg,
+ sizeof (msg),
+ "Received %zu bytes\n",
+ ctx->body_len);
+ return reply_text (con,
+ MHD_HTTP_OK,
+ msg);
+ }
+
+ /* PUT /put */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_PUT)) &&
+ (0 == strcmp (url,
+ "/put")) )
+ {
+ char msg[64];
+
+ snprintf (msg,
+ sizeof (msg),
+ "PUT received %zu\n",
+ ctx->body_len);
+ return reply_text (con,
+ MHD_HTTP_OK,
+ msg);
+ }
+
+ /* PATCH /patch */
+ if ( (0 == strcmp (method,
+ MHD_HTTP_METHOD_PATCH)) &&
+ (0 == strcmp (url,
+ "/patch")) )
+ {
+ char msg[64];
+
+ snprintf (msg,
+ sizeof (msg),
+ "PATCH received %zu\n",
+ ctx->body_len);
+ return reply_text (con,
+ MHD_HTTP_OK,
+ msg);
+ }
+
+ /* DELETE /item */
+ if ( (0 == strcmp (method,
+ "DELETE")) &&
+ (0 == strncmp (url,
+ "/item",
+ strlen ("/item"))) )
+ {
+ struct MHD_Response *r = make_text_response ("");
+ enum MHD_Result ret = MHD_queue_response (con,
+ MHD_HTTP_NO_CONTENT,
+ r);
+ MHD_destroy_response (r);
+ return ret;
+ }
+
+ return reply_text (con,
+ MHD_HTTP_NOT_FOUND,
+ "not found\n");
+}
+
+
+static void
+completed_cb (void *cls,
+ struct MHD_Connection *con,
+ void **con_cls,
+ enum MHD_RequestTerminationCode toe)
+{
+ struct Ctx *ctx = *con_cls;
+
+ (void) cls;
+ (void) con;
+ (void) toe;
+ if (NULL != ctx)
+ ctx_free (ctx);
+ *con_cls = NULL;
+}
+
+
+static volatile sig_atomic_t run_flag = 1;
+
+static void
+on_int (int s)
+{
+ (void) s;
+ run_flag = 0;
+}
+
+
+int
+main (int argc, char **argv)
+{
+ unsigned int port = 8401;
+ struct MHD_Daemon *d;
+
+ if (argc > 1)
+ port = (unsigned int) atoi (argv[1]);
+ signal (SIGINT,
+ on_int);
+ signal (SIGTERM,
+ on_int);
+ signal (SIGPIPE,
+ SIG_IGN);
+ d = MHD_start_daemon (MHD_USE_INTERNAL_POLLING_THREAD
+ | MHD_USE_DUAL_STACK,
+ port,
+ NULL, NULL,
+ &handler, NULL,
+ MHD_OPTION_NOTIFY_COMPLETED, &completed_cb, NULL,
+ MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int) 30,
+ MHD_OPTION_END);
+ if (NULL == d)
+ {
+ fprintf (stderr,
+ "MHD_start_daemon failed on port %u\n",
+ port);
+ return 1;
+ }
+ fprintf (stderr,
+ "upstream_mhd listening on port %u\n",
+ port);
+ fflush (stderr);
+ while (run_flag)
+ sleep (1);
+ MHD_stop_daemon (d);
+ return 0;
+}
diff --git a/src/tests/upstream_py.py b/src/tests/upstream_py.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+# upstream_py: Python-based upstream HTTP server used by the
+# paivana reverse-proxy tests. Implements the same small set
+# of canned endpoints as upstream_mhd.c.
+
+import sys
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+UPSTREAM_NAME = "py"
+
+
+class Handler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, fmt, *args):
+ sys.stderr.write("upstream_py: " + (fmt % args) + "\n")
+
+ def _send_text(self, code, body, content_type="text/plain"):
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ self.send_response(code)
+ self.send_header("X-Upstream", UPSTREAM_NAME)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _read_body(self):
+ cl = int(self.headers.get("Content-Length", "0") or 0)
+ if cl > 0:
+ return self.rfile.read(cl)
+ return b""
+
+ def do_OPTIONS(self):
+ self.send_response(204)
+ self.send_header("X-Upstream", UPSTREAM_NAME)
+ self.send_header("Allow",
+ "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+
+ def do_HEAD(self):
+ if self.path == "/hello" or self.path.startswith("/large/"):
+ # no body regardless
+ body_len = 0
+ if self.path.startswith("/large/"):
+ try:
+ n = int(self.path[len("/large/"):])
+ body_len = max(0, min(n, 10 * 1024 * 1024))
+ except ValueError:
+ body_len = 0
+ self.send_response(200)
+ self.send_header("X-Upstream", UPSTREAM_NAME)
+ self.send_header("Content-Type", "application/octet-stream")
+ self.send_header("Content-Length", str(body_len))
+ self.end_headers()
+ return
+ self._send_text(404, "not found\n")
+
+ def do_GET(self):
+ if self.path == "/hello":
+ self._send_text(200, f"Hello from {UPSTREAM_NAME}\n")
+ return
+ if self.path.startswith("/status/"):
+ try:
+ code = int(self.path[len("/status/"):])
+ except ValueError:
+ code = 500
+ if code < 100 or code > 599:
+ code = 500
+ self._send_text(code, f"status {code}\n")
+ return
+ if self.path.startswith("/large/"):
+ try:
+ n = int(self.path[len("/large/"):])
+ except ValueError:
+ n = 0
+ n = max(0, min(n, 10 * 1024 * 1024))
+ buf = bytes(((ord('A') + i % 26) for i in range(n)))
+ self._send_text(200, buf, "application/octet-stream")
+ return
+ if self.path.startswith("/slow/"):
+ try:
+ ms = int(self.path[len("/slow/"):])
+ except ValueError:
+ ms = 0
+ ms = max(0, min(ms, 30000))
+ time.sleep(ms / 1000.0)
+ self._send_text(200, "slept\n")
+ return
+ if self.path == "/echo-headers":
+ parts = []
+ for k, v in self.headers.items():
+ parts.append(f"{k}: {v}\n")
+ self._send_text(200, "".join(parts))
+ return
+ self._send_text(404, "not found\n")
+
+ def do_POST(self):
+ body = self._read_body()
+ if self.path == "/echo":
+ self._send_text(200, body, "application/octet-stream")
+ return
+ if self.path == "/upload":
+ self._send_text(200, f"Received {len(body)} bytes\n")
+ return
+ self._send_text(404, "not found\n")
+
+ def do_PUT(self):
+ body = self._read_body()
+ if self.path == "/put":
+ self._send_text(200, f"PUT received {len(body)}\n")
+ return
+ self._send_text(404, "not found\n")
+
+ def do_PATCH(self):
+ body = self._read_body()
+ if self.path == "/patch":
+ self._send_text(200, f"PATCH received {len(body)}\n")
+ return
+ self._send_text(404, "not found\n")
+
+ def do_DELETE(self):
+ # drain any body
+ self._read_body()
+ if self.path.startswith("/item"):
+ self.send_response(204)
+ self.send_header("X-Upstream", UPSTREAM_NAME)
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+ return
+ self._send_text(404, "not found\n")
+
+
+def main():
+ port = 8403
+ if len(sys.argv) > 1:
+ port = int(sys.argv[1])
+ server = ThreadingHTTPServer(("", port), Handler)
+ sys.stderr.write(f"upstream_py listening on port {port}\n")
+ sys.stderr.flush()
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/tests/upstream_rs.rs b/src/tests/upstream_rs.rs
@@ -0,0 +1,208 @@
+/*
+ This file is part of Paivana.
+ 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.
+*/
+
+// upstream_rs: Rust-based upstream HTTP server used by the
+// paivana reverse-proxy tests. Implements the same small set
+// of canned endpoints as upstream_mhd.c, using only the std
+// library so it can be built with just `rustc`.
+
+use std::env;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::net::{TcpListener, TcpStream};
+use std::thread;
+use std::time::Duration;
+
+const UPSTREAM: &str = "rs";
+
+struct Request {
+ method: String,
+ path: String,
+ headers: Vec<(String, String)>,
+ body: Vec<u8>,
+}
+
+fn parse_request(stream: &mut TcpStream) -> Option<Request> {
+ let mut br = BufReader::new(stream);
+ let mut line = String::new();
+ if br.read_line(&mut line).ok()? == 0 {
+ return None;
+ }
+ let mut parts = line.trim_end().splitn(3, ' ');
+ let method = parts.next()?.to_string();
+ let path = parts.next()?.to_string();
+ let mut headers = Vec::new();
+ loop {
+ let mut h = String::new();
+ if br.read_line(&mut h).ok()? == 0 {
+ break;
+ }
+ let t = h.trim_end();
+ if t.is_empty() {
+ break;
+ }
+ if let Some(idx) = t.find(':') {
+ let k = t[..idx].trim().to_string();
+ let v = t[idx + 1..].trim().to_string();
+ headers.push((k, v));
+ }
+ }
+ // Read body if there's a Content-Length
+ let cl: usize = headers
+ .iter()
+ .find(|(k, _)| k.eq_ignore_ascii_case("Content-Length"))
+ .and_then(|(_, v)| v.parse().ok())
+ .unwrap_or(0);
+ let mut body = vec![0u8; cl];
+ if cl > 0 {
+ if br.read_exact(&mut body).is_err() {
+ return None;
+ }
+ }
+ Some(Request {
+ method,
+ path,
+ headers,
+ body,
+ })
+}
+
+fn send_response(stream: &mut TcpStream, code: u16, reason: &str,
+ content_type: &str, body: &[u8], extra_headers: &[(&str, &str)]) {
+ let mut head = format!(
+ "HTTP/1.1 {} {}\r\nX-Upstream: {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n",
+ code,
+ reason,
+ UPSTREAM,
+ content_type,
+ body.len()
+ );
+ for (k, v) in extra_headers {
+ head.push_str(&format!("{}: {}\r\n", k, v));
+ }
+ head.push_str("Connection: keep-alive\r\n\r\n");
+ let _ = stream.write_all(head.as_bytes());
+ let _ = stream.write_all(body);
+}
+
+fn handle(req: &Request, stream: &mut TcpStream) {
+ if req.method == "OPTIONS" {
+ send_response(
+ stream, 204, "No Content", "text/plain", &[],
+ &[("Allow", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")],
+ );
+ return;
+ }
+ if req.path == "/hello" && (req.method == "GET" || req.method == "HEAD") {
+ let body = format!("Hello from {}\n", UPSTREAM);
+ let b: &[u8] = if req.method == "HEAD" { &[] } else { body.as_bytes() };
+ // For HEAD, still advertise correct Content-Length of the would-be body.
+ if req.method == "HEAD" {
+ let head = format!(
+ "HTTP/1.1 200 OK\r\nX-Upstream: {}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n",
+ UPSTREAM,
+ body.len()
+ );
+ let _ = stream.write_all(head.as_bytes());
+ } else {
+ send_response(stream, 200, "OK", "text/plain", b, &[]);
+ }
+ return;
+ }
+ if let Some(rest) = req.path.strip_prefix("/status/") {
+ let code: u16 = rest.parse().unwrap_or(500);
+ let code = if !(100..=599).contains(&code) { 500 } else { code };
+ let body = format!("status {}\n", code);
+ send_response(stream, code, "Status", "text/plain", body.as_bytes(), &[]);
+ return;
+ }
+ if let Some(rest) = req.path.strip_prefix("/large/") {
+ let n: usize = rest.parse().unwrap_or(0).min(10 * 1024 * 1024);
+ let mut buf = Vec::with_capacity(n);
+ for i in 0..n {
+ buf.push(b'A' + ((i % 26) as u8));
+ }
+ send_response(stream, 200, "OK", "application/octet-stream", &buf, &[]);
+ return;
+ }
+ if let Some(rest) = req.path.strip_prefix("/slow/") {
+ let ms: u64 = rest.parse().unwrap_or(0).min(30000);
+ thread::sleep(Duration::from_millis(ms));
+ send_response(stream, 200, "OK", "text/plain", b"slept\n", &[]);
+ return;
+ }
+ if req.path == "/echo-headers" && req.method == "GET" {
+ let mut b = String::new();
+ for (k, v) in &req.headers {
+ b.push_str(&format!("{}: {}\n", k, v));
+ }
+ send_response(stream, 200, "OK", "text/plain", b.as_bytes(), &[]);
+ return;
+ }
+ if req.path == "/echo" && req.method == "POST" {
+ send_response(stream, 200, "OK", "application/octet-stream", &req.body, &[]);
+ return;
+ }
+ if req.path == "/upload" && req.method == "POST" {
+ let b = format!("Received {} bytes\n", req.body.len());
+ send_response(stream, 200, "OK", "text/plain", b.as_bytes(), &[]);
+ return;
+ }
+ if req.path == "/put" && req.method == "PUT" {
+ let b = format!("PUT received {}\n", req.body.len());
+ send_response(stream, 200, "OK", "text/plain", b.as_bytes(), &[]);
+ return;
+ }
+ if req.path == "/patch" && req.method == "PATCH" {
+ let b = format!("PATCH received {}\n", req.body.len());
+ send_response(stream, 200, "OK", "text/plain", b.as_bytes(), &[]);
+ return;
+ }
+ if req.path.starts_with("/item") && req.method == "DELETE" {
+ send_response(stream, 204, "No Content", "text/plain", &[], &[]);
+ return;
+ }
+ send_response(stream, 404, "Not Found", "text/plain", b"not found\n", &[]);
+}
+
+fn client_loop(mut stream: TcpStream) {
+ // We can't easily loop keep-alive with our BufReader pattern without
+ // ownership gymnastics; handle one request per connection. paivana
+ // opens fresh connections to us, so this is fine.
+ if let Some(req) = parse_request(&mut stream) {
+ handle(&req, &mut stream);
+ }
+}
+
+fn main() {
+ let port: u16 = env::args()
+ .nth(1)
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(8404);
+ let listener = TcpListener::bind(("0.0.0.0", port)).expect("bind failed");
+ eprintln!("upstream_rs listening on port {}", port);
+ for stream in listener.incoming() {
+ match stream {
+ Ok(s) => {
+ thread::spawn(move || client_loop(s));
+ }
+ Err(_) => continue,
+ }
+ }
+}