paivana

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

commit b3a51664bef1f27d33a67ff8ff809c895175734b
parent b4e1c8b1d4fde98fcc3b7f4d933e8a878a15d657
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu, 23 Apr 2026 22:39:00 +0200

add reverse proxy tests

Diffstat:
Msrc/backend/meson.build | 2+-
Msrc/meson.build | 1+
Asrc/tests/README | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/meson.build | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/pipeline_client.c | 391+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/test_reverse_proxy.conf.in | 14++++++++++++++
Asrc/tests/test_reverse_proxy.sh | 532+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/upstream_go.go | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/upstream_mhd.c | 467+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/upstream_py.py | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/upstream_rs.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, + } + } +}