paivana

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

test_reverse_proxy.sh (102613B)


      1 #!/bin/bash
      2 #
      3 # Reverse-proxy integration tests for paivana.
      4 #
      5 # Starts upstream HTTP servers (one per language: C/MHD, Go, Python, Rust)
      6 # and a paivana-httpd instance running with -n (paywall disabled), then
      7 # exercises the reverse-proxy behaviors with curl, wget, and a custom
      8 # libcurl / raw-socket pipelining client.
      9 #
     10 # Progress markers: each test prints "<description> " with no newline,
     11 # then "OK" on pass or "FAIL: <detail>" on failure.  Failure exits
     12 # non-zero, which make(1) treats as a TEST FAILURE.
     13 #
     14 # Environment variables honored:
     15 #   PAIVANA_HTTPD   path to paivana-httpd binary (default: built
     16 #                   in the sibling src/backend tree)
     17 #   SRCDIR          source dir containing .py / .rs / .go sources
     18 #                   (default: directory of this script)
     19 #   BUILDDIR        directory holding upstream_mhd, upstream_go,
     20 #                   upstream_rs, pipeline_client (default: $PWD)
     21 #   KEEP_TMP=1      keep the scratch dir and log files after exit
     22 #   PAIVANA_PORT_BASE
     23 #                   first port of the ten the suite binds (default
     24 #                   18400); move it to run two checkouts at once
     25 #
     26 set -u
     27 
     28 function die() {
     29     echo "FAIL: $*" >&2
     30     exit 1
     31 }
     32 
     33 function msg() {
     34     printf '%s ' "$*"
     35 }
     36 
     37 function ok() {
     38     echo "OK"
     39 }
     40 
     41 function fail() {
     42     echo "FAIL: $*"
     43     dump_logs
     44     exit 1
     45 }
     46 
     47 function here() {
     48     cd -- "$(dirname -- "$0")" && pwd
     49 }
     50 
     51 SRCDIR="${SRCDIR:-$(here)}"
     52 BUILDDIR="${BUILDDIR:-$PWD}"
     53 
     54 # Default to the in-tree build path.
     55 PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}"
     56 if [ ! -x "$PAIVANA_HTTPD" ];
     57 then
     58     # Try the source layout (e.g. when tests are run from source tree)
     59     alt="$SRCDIR/../backend/paivana-httpd"
     60     if [ -x "$alt" ];
     61     then
     62         PAIVANA_HTTPD="$alt"
     63     fi
     64 fi
     65 
     66 if [ ! -x "$PAIVANA_HTTPD" ];
     67 then
     68     echo "SKIP: paivana-httpd binary not found (looked at $PAIVANA_HTTPD)" >&2
     69     exit 77
     70 fi
     71 
     72 # Binaries/commands for upstreams
     73 UPSTREAM_MHD="$BUILDDIR/upstream_mhd"
     74 UPSTREAM_GO="$BUILDDIR/upstream_go"
     75 UPSTREAM_RS="$BUILDDIR/upstream_rs"
     76 PIPELINE_CLIENT="$BUILDDIR/pipeline_client"
     77 EARLY_RESPONSE_UPSTREAM="$BUILDDIR/early_response_upstream"
     78 
     79 # Ports.  Every one of them is an offset off a single base so that the
     80 # whole block can be moved out of the way: two checkouts of this repo
     81 # (or two CI jobs on one machine) running the suite at once would
     82 # otherwise fight over the same ten fixed numbers, and the loser reads
     83 # as a paivana bug rather than as a collision.  The default keeps the
     84 # historical numbering.  require_ports_free() below refuses to run at
     85 # all when one of them is taken.
     86 PORT_BASE="${PAIVANA_PORT_BASE:-18400}"
     87 MHD_PORT=$((PORT_BASE + 1))
     88 GO_PORT=$((PORT_BASE + 2))
     89 PY_PORT=$((PORT_BASE + 3))
     90 RS_PORT=$((PORT_BASE + 4))
     91 EARLY_PORT=$((PORT_BASE + 5))
     92 NODRAIN_PORT=$((PORT_BASE + 6))
     93 TRUNC_PORT=$((PORT_BASE + 7))
     94 STREAM_PORT=$((PORT_BASE + 8))
     95 DEAD_PORT=$((PORT_BASE + 99))    # nothing may be listening here
     96 PAIVANA_PORT=$((PORT_BASE + 100))
     97 
     98 # NOT named TMPDIR.  That is the standard variable every child process
     99 # reads for its own temporary files, bash keeps the export attribute an
    100 # inherited TMPDIR came with, and cleanup() rm -rf's this directory
    101 # while paivana, the upstreams and curl may still be running out of it.
    102 SCRATCH="$(mktemp -d -t paivana-tests.XXXXXX)"
    103 LOGDIR="$SCRATCH/logs"
    104 mkdir -p "$LOGDIR"
    105 
    106 # paivana normally resolves its config.d via the install prefix.
    107 # Tests run against the uninstalled build tree, so point the
    108 # project's base-config override at an empty directory: no
    109 # auxiliary config snippets are needed for reverse-proxy tests.
    110 BASE_CONFIG_DIR="$SCRATCH/configd"
    111 mkdir -p "$BASE_CONFIG_DIR"
    112 export PAIVANA_BASE_CONFIG="$BASE_CONFIG_DIR"
    113 
    114 PIDS=()
    115 PAIVANA_PID=""
    116 # DESTINATION_BASE_URL the running paivana was configured with; the
    117 # battery derives from it the Host header paivana should be sending
    118 # upstream.
    119 PAIVANA_DEST=""
    120 # Path of the listening socket when paivana was started by
    121 # start_paivana_unix(); empty for the TCP cases.
    122 PAIVANA_SOCK=""
    123 
    124 function dump_logs() {
    125     echo "-- logs in $LOGDIR --" >&2
    126     for f in "$LOGDIR"/*.log; do
    127         [ -e "$f" ] || continue
    128         echo "==> $f <==" >&2
    129         tail -n 40 "$f" >&2
    130     done
    131 }
    132 
    133 function cleanup() {
    134     set +e
    135     for p in "${PIDS[@]:-}";
    136     do
    137         [ -n "$p" ] && kill -TERM "$p" 2>/dev/null
    138     done
    139     [ -n "$PAIVANA_PID" ] && kill -TERM "$PAIVANA_PID" 2>/dev/null
    140     # Give them a moment to exit cleanly
    141     sleep 0.2
    142     for p in "${PIDS[@]:-}";
    143     do
    144         [ -n "$p" ] && kill -KILL "$p" 2>/dev/null
    145     done
    146     [ -n "$PAIVANA_PID" ] && kill -KILL "$PAIVANA_PID" 2>/dev/null
    147     if [ "${KEEP_TMP:-0}" = "1" ];
    148     then
    149         echo "Temp files kept in $SCRATCH" >&2
    150     else
    151         rm -rf "$SCRATCH"
    152     fi
    153 }
    154 trap cleanup EXIT
    155 trap 'echo "FAIL: interrupted" >&2; exit 1' INT TERM
    156 
    157 # Does a TCP connect to the given port fail?  Used both as the
    158 # "nothing is squatting here" precondition and, negated, as the
    159 # readiness probe.
    160 #
    161 # NOTE: the /dev/tcp probe must run in a subshell — `exec` on the
    162 # parent shell with a failing redirection would terminate bash in
    163 # non-interactive mode (the 2>/dev/null does not suppress that).
    164 function port_is_free() {
    165     local host="$1" port="$2"
    166 
    167     if ( exec 7<>"/dev/tcp/$host/$port" ) 2>/dev/null;
    168     then
    169         return 1
    170     fi
    171     return 0
    172 }
    173 
    174 function require_ports_free() {
    175     # The suite's own documentation promised this and nothing did it.
    176     # A stranger on PAIVANA_PORT is the damaging case: our paivana dies
    177     # of EADDRINUSE, the readiness probe below sees the squatter accept
    178     # and reports "started", and every check then runs against the
    179     # wrong process -- with a stale paivana of a different vintage the
    180     # checks even pass.  DEAD_PORT has to be free for the mirror-image
    181     # reason: test_upstream_down asserts that connecting to it fails.
    182     #
    183     # This is an environment problem rather than a regression, so it is
    184     # a skip (meson reads 77 as SKIP), not a failure.
    185     local busy=""
    186 
    187     for p in "$@";
    188     do
    189         port_is_free 127.0.0.1 "$p" || busy="$busy $p"
    190     done
    191     if [ -n "$busy" ];
    192     then
    193         echo "SKIP: port(s) already in use:$busy" >&2
    194         echo "Another copy of this suite, or a stale paivana-httpd, is" \
    195              "holding them; re-run with PAIVANA_PORT_BASE set to a free" \
    196              "block of 101 ports (current base: $PORT_BASE)." >&2
    197         exit 77
    198     fi
    199 }
    200 
    201 function wait_for_port() {
    202     # Block until a TCP port accepts a connection (max ~5s), giving up
    203     # the moment the process that was supposed to bind it is gone.
    204     #
    205     # Without the liveness check this only asks "is *something*
    206     # listening", which is not the same question: a child that died of
    207     # EADDRINUSE (or of a config it refused) reads as started, and the
    208     # caller happily runs its checks against whoever holds the port.
    209     # It is also what made a refused startup cost the full 5 s of
    210     # retries instead of the milliseconds the child actually took to
    211     # exit -- seven of those were most of the suite's wall-clock.
    212     #
    213     # $3 is optional so that a caller with no pid to offer (a helper
    214     # started by some other means) still works, just without either
    215     # benefit.
    216     local host="$1" port="$2" pid="${3:-}" tries=50
    217 
    218     while [ "$tries" -gt 0 ];
    219     do
    220         if ! port_is_free "$host" "$port";
    221         then
    222             return 0
    223         fi
    224         # Order matters: probe first, so that "the port is up" always
    225         # wins over "the pid we were given is gone" -- a child that
    226         # handed the listening socket on and exited is still a service
    227         # that came up.
    228         if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null;
    229         then
    230             return 1
    231         fi
    232         sleep 0.1
    233         tries=$((tries - 1))
    234     done
    235     return 1
    236 }
    237 
    238 # Start a background upstream; record pid in PIDS.
    239 function start_bg() {
    240     local name="$1" port="$2"; shift 2
    241     local log="$LOGDIR/$name.log"
    242     ( exec "$@" "$port" ) >"$log" 2>&1 &
    243     local pid=$!
    244     PIDS+=("$pid")
    245     if ! wait_for_port 127.0.0.1 "$port" "$pid";
    246     then
    247         echo "FAIL: $name did not start on port $port" >&2
    248         tail -n 20 "$log" >&2
    249         exit 1
    250     fi
    251 }
    252 
    253 function start_paivana() {
    254     # $1 = upstream base URL; any further arguments are passed to
    255     # paivana-httpd verbatim.  Note the shift: quoting "$@" (rather
    256     # than a single "$flags" string) is what keeps a caller that
    257     # passes no extra flags from handing paivana an empty argument.
    258     local dest="$1"; shift
    259     PAIVANA_DEST="$dest"
    260     local cfg="$SCRATCH/paivana.conf"
    261     sed -e "s|@DEST@|$dest|g" -e "s|@PORT@|$PAIVANA_PORT|g" \
    262         "$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
    263     local log="$LOGDIR/paivana.log"
    264     ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING "$@" ) >"$log" 2>&1 &
    265     PAIVANA_PID=$!
    266     if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT" "$PAIVANA_PID";
    267     then
    268         echo "FAIL: paivana-httpd did not start on port $PAIVANA_PORT" >&2
    269         tail -n 20 "$log" >&2
    270         exit 1
    271     fi
    272 }
    273 
    274 # Start the TCP daemon with extra configuration lines.  BIND_TO keeps this on
    275 # one MHD daemon so small connection-limit tests exercise the process-wide
    276 # arithmetic rather than dividing their tiny budget across IPv4 and IPv6.
    277 function start_paivana_with_config() {
    278     local dest="$1" extra="$2" level="${3:-WARNING}"
    279     PAIVANA_DEST="$dest"
    280     local cfg="$SCRATCH/paivana-custom.conf"
    281     sed -e "s|@DEST@|$dest|g" -e "s|@PORT@|$PAIVANA_PORT|g" \
    282         "$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
    283     printf 'BIND_TO = 127.0.0.1\n%s\n' "$extra" >> "$cfg"
    284     local log="$LOGDIR/paivana-custom.log"
    285     ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L "$level" ) >"$log" 2>&1 &
    286     PAIVANA_PID=$!
    287     if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT" "$PAIVANA_PID";
    288     then
    289         echo "FAIL: custom paivana-httpd did not start" >&2
    290         tail -n 40 "$log" >&2
    291         exit 1
    292     fi
    293 }
    294 
    295 function wait_for_unix_socket() {
    296     # Block until the given path exists and is a socket (max ~5s), or
    297     # until the process that was to create it is gone.  Same reasoning
    298     # as wait_for_port: a leftover socket file from an earlier run is
    299     # the Unix-domain spelling of a squatter on the port.
    300     local path="$1" pid="${2:-}" tries=50
    301 
    302     while [ "$tries" -gt 0 ];
    303     do
    304         [ -S "$path" ] && return 0
    305         if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null;
    306         then
    307             return 1
    308         fi
    309         sleep 0.1
    310         tries=$((tries - 1))
    311     done
    312     return 1
    313 }
    314 
    315 function start_paivana_unix() {
    316     # Like start_paivana, but listening on a Unix socket rather than
    317     # TCP -- the shape the shipped packaging deploys.  A Unix peer has
    318     # no address, which is exactly what makes it worth testing.
    319     # $1 = upstream base URL; further arguments go to paivana-httpd.
    320     local dest="$1"; shift
    321     PAIVANA_DEST="$dest"
    322     local cfg="$SCRATCH/paivana-unix.conf"
    323     PAIVANA_SOCK="$SCRATCH/paivana.sock"
    324     rm -f "$PAIVANA_SOCK"
    325     sed -e "s|@DEST@|$dest|g" -e "s|@UNIXPATH@|$PAIVANA_SOCK|g" \
    326         "$SRCDIR/test_reverse_proxy_unix.conf.in" > "$cfg"
    327     local log="$LOGDIR/paivana-unix.log"
    328     ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING "$@" ) >"$log" 2>&1 &
    329     PAIVANA_PID=$!
    330     if ! wait_for_unix_socket "$PAIVANA_SOCK" "$PAIVANA_PID";
    331     then
    332         echo "FAIL: paivana-httpd did not create $PAIVANA_SOCK" >&2
    333         tail -n 20 "$log" >&2
    334         exit 1
    335     fi
    336 }
    337 
    338 function stop_paivana() {
    339     if [ -n "$PAIVANA_PID" ];
    340     then
    341         kill -TERM "$PAIVANA_PID" 2>/dev/null
    342         wait "$PAIVANA_PID" 2>/dev/null
    343         PAIVANA_PID=""
    344     fi
    345 }
    346 
    347 # Start all upstreams that we have binaries for.
    348 function start_upstreams() {
    349     start_bg mhd "$MHD_PORT" "$UPSTREAM_MHD"
    350     if [ -x "$UPSTREAM_GO" ];
    351     then
    352         start_bg go "$GO_PORT" "$UPSTREAM_GO"
    353     else
    354         echo "NOTE: upstream_go not built, skipping Go upstream tests" >&2
    355         GO_PORT=""
    356     fi
    357     if [ -x "$UPSTREAM_RS" ];
    358     then
    359         start_bg rs "$RS_PORT" "$UPSTREAM_RS"
    360     else
    361         echo "NOTE: upstream_rs not built, skipping Rust upstream tests" >&2
    362         RS_PORT=""
    363     fi
    364     if command -v python3 >/dev/null 2>&1;
    365     then
    366         local log="$LOGDIR/py.log"
    367         ( exec python3 "$SRCDIR/upstream_py.py" "$PY_PORT" ) >"$log" 2>&1 &
    368         local pypid=$!
    369         PIDS+=("$pypid")
    370         if ! wait_for_port 127.0.0.1 "$PY_PORT" "$pypid";
    371         then
    372             echo "FAIL: upstream_py did not start on port $PY_PORT" >&2
    373             tail -n 20 "$log" >&2
    374             exit 1
    375         fi
    376     else
    377         echo "NOTE: python3 not available, skipping Python upstream tests" >&2
    378         PY_PORT=""
    379     fi
    380 }
    381 
    382 ######################################################################
    383 # Test helpers
    384 ######################################################################
    385 
    386 PAIVANA_URL() { echo "http://127.0.0.1:$PAIVANA_PORT$1"; }
    387 
    388 # GET via curl; verifies status code and a substring of the body.
    389 function test_get() {
    390     local desc="$1" path="$2" want_status="$3" want_sub="$4"
    391     msg "$desc"
    392     local out status
    393     out="$(curl -sS -o "$SCRATCH/body" -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$SCRATCH/err")" \
    394         || { fail "curl: $(cat "$SCRATCH/err")"; }
    395     status="$out"
    396     [ "$status" = "$want_status" ] || fail "status=$status want=$want_status"
    397     # -F: $want_sub is a substring, not a pattern.  Without it a '.'
    398     # in an expected body matches anything and the check passes on a
    399     # body it should have rejected.
    400     if [ -n "$want_sub" ] && ! grep -qF -- "$want_sub" "$SCRATCH/body";
    401     then
    402         fail "body missing substring '$want_sub' (got: $(tr -d '\n' <"$SCRATCH/body" | head -c 120))"
    403     fi
    404     ok
    405 }
    406 
    407 # HEAD.  Status only; the "no content" half is test_head_no_body().
    408 function test_head() {
    409     local desc="$1" path="$2" want_status="$3"
    410     msg "$desc"
    411     local status
    412     status="$(curl -sS -I -o /dev/null -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$SCRATCH/err")" \
    413         || fail "curl: $(cat "$SCRATCH/err")"
    414     [ "$status" = "$want_status" ] || fail "status=$status want=$want_status"
    415     ok
    416 }
    417 
    418 # RFC 9110 section 9.3.2's one normative requirement on HEAD -- "the
    419 # server MUST NOT send content in the response" -- read off the wire
    420 # rather than through curl, which discards a body a HEAD response has
    421 # no business carrying and would therefore report the bug as a pass.
    422 # The path is one that produces 128 KiB under GET, so a proxy that
    423 # forgot the method has something to leak.
    424 function test_head_no_body() {
    425     local label="$1"
    426     msg "[$label] HEAD /large/131072 carries no content (RFC 9110 9.3.2)"
    427     raw_head "127.0.0.1" "$PAIVANA_PORT" "$SCRATCH/head_raw"
    428     case "$(head -c 12 "$SCRATCH/head_raw")" in
    429         'HTTP/1.1 200'*) ;;
    430         *) fail "expected 200 status line, got: $(head -c 80 "$SCRATCH/head_raw")";;
    431     esac
    432     # Bytes after the first empty line.  Zero for a conforming HEAD
    433     # response; anything else is content that must not be there.
    434     local extra
    435     extra="$(tr -d '\r' <"$SCRATCH/head_raw" \
    436              | awk 'seen { n += length ($0) + 1 }
    437                     /^$/ && ! seen { seen = 1 }
    438                     END { print n + 0 }')"
    439     [ "$extra" = "0" ] || \
    440         fail "HEAD response carried $extra bytes of content"
    441     ok
    442 }
    443 
    444 # Raw HEAD /large/131072 against $1:$2, whole response into $3.  Raw
    445 # rather than curl, which discards a body a HEAD response has no
    446 # business carrying and would report the bug as a pass.
    447 function raw_head() {
    448     local host="$1" port="$2" out="$3"
    449 
    450     ( exec 3<>"/dev/tcp/$host/$port"
    451       printf 'HEAD /large/131072 HTTP/1.1\r\nHost: %s:%s\r\nConnection: close\r\n\r\n' \
    452              "$host" "$port" >&3
    453       timeout 10 cat <&3 >"$out" ) 2>"$SCRATCH/err" \
    454         || fail "raw HEAD to $host:$port failed: $(cat "$SCRATCH/err")"
    455 }
    456 
    457 # Write @2 with @1 bytes of the 'A'..'Z' cycle every upstream serves
    458 # from /large/N, so that the response can be compared byte for byte
    459 # rather than merely counted.  The chunk stays a whole number of
    460 # 26-byte cycles, which is what keeps it aligned when repeated.
    461 function make_large_pattern() {
    462     local want="$1" out="$2"
    463     local chunk='ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    464     local n i
    465 
    466     while [ "${#chunk}" -lt 65536 ];
    467     do
    468         chunk="$chunk$chunk"
    469     done
    470     n=$(( (want + ${#chunk} - 1) / ${#chunk} ))
    471     for ((i = 0; i < n; i++));
    472     do
    473         printf '%s' "$chunk"
    474     done | head -c "$want" >"$out"
    475 }
    476 
    477 # Generic method with optional body; checks status and body substring.
    478 function test_method() {
    479     local desc="$1" method="$2" path="$3" body="$4" want_status="$5" want_sub="$6"
    480     msg "$desc"
    481     local args=(-sS -o "$SCRATCH/body" -w '%{http_code}' -X "$method" "$(PAIVANA_URL "$path")")
    482     if [ -n "$body" ];
    483     then
    484         args+=(--data-binary "@$body")
    485     fi
    486     local status
    487     status="$(curl "${args[@]}" 2>"$SCRATCH/err")" \
    488         || fail "curl: $(cat "$SCRATCH/err")"
    489     [ "$status" = "$want_status" ] || \
    490         fail "status=$status want=$want_status; body=$(head -c 200 "$SCRATCH/body")"
    491     if [ -n "$want_sub" ] && ! grep -qF -- "$want_sub" "$SCRATCH/body";
    492     then
    493         fail "body missing substring '$want_sub' (got: $(head -c 200 "$SCRATCH/body"))"
    494     fi
    495     ok
    496 }
    497 
    498 ######################################################################
    499 # Test battery — runs against whichever upstream we've pointed
    500 # paivana at.
    501 ######################################################################
    502 
    503 function run_battery() {
    504     local label="$1"
    505 
    506     test_get "[$label] GET /hello (basic proxy pass-through)" \
    507         /hello 200 "Hello from"
    508 
    509     test_get "[$label] GET /status/201 (2xx status forwarding)" \
    510         /status/201 201 "status 201"
    511 
    512     test_get "[$label] GET /status/404 (4xx status forwarding)" \
    513         /status/404 404 "status 404"
    514 
    515     test_get "[$label] GET /status/500 (5xx status forwarding)" \
    516         /status/500 500 "status 500"
    517 
    518     test_head "[$label] HEAD /hello" /hello 200
    519     test_head_no_body "$label"
    520 
    521     # 128 KiB response body, compared byte for byte against the
    522     # 'A'..'Z' cycle every upstream generates.  A length check alone
    523     # would pass on a body that arrived complete but scrambled --
    524     # a chunk delivered twice and another dropped, say -- which is
    525     # precisely the failure a buffering proxy has to be shown not to
    526     # have.
    527     test_get "[$label] GET /large/131072 (128 KiB response)" \
    528         /large/131072 200 ""
    529     msg "[$label] 128 KiB response body is byte-for-byte intact"
    530     local sz
    531     sz="$(wc -c <"$SCRATCH/body" | tr -d ' ')"
    532     [ "$sz" = "131072" ] || fail "got $sz bytes, expected 131072"
    533     make_large_pattern 131072 "$SCRATCH/large_want"
    534     cmp -s "$SCRATCH/large_want" "$SCRATCH/body" || \
    535         fail "128 KiB body differs from the expected pattern at $(cmp "$SCRATCH/large_want" "$SCRATCH/body" 2>&1 | head -1)"
    536     ok
    537 
    538     # POST /echo — round-trip body
    539     printf 'hello-payload-%s' "$label" >"$SCRATCH/post_body"
    540     test_method "[$label] POST /echo (body round-trip)" \
    541         POST /echo "$SCRATCH/post_body" 200 "hello-payload-$label"
    542 
    543     # The same in the request direction, and at a size that does not
    544     # fit in one buffer: 128 KiB of random bytes posted to /echo and
    545     # compared with what comes back.  POST /upload below checks only
    546     # the count the upstream reports, so without this nothing in the
    547     # suite would notice a request body that arrived complete but
    548     # corrupt.
    549     msg "[$label] POST /echo 128 KiB round-trips byte for byte"
    550     dd if=/dev/urandom of="$SCRATCH/echo_big" bs=1024 count=128 status=none
    551     local estatus
    552     estatus="$(curl -sS -X POST --data-binary "@$SCRATCH/echo_big" \
    553                     -o "$SCRATCH/body" -w '%{http_code}' \
    554                     "$(PAIVANA_URL /echo)" 2>"$SCRATCH/err")" \
    555         || fail "curl: $(cat "$SCRATCH/err")"
    556     [ "$estatus" = "200" ] || fail "status=$estatus want=200"
    557     cmp -s "$SCRATCH/echo_big" "$SCRATCH/body" || \
    558         fail "echoed 128 KiB body differs: $(cmp "$SCRATCH/echo_big" "$SCRATCH/body" 2>&1 | head -1)"
    559     ok
    560 
    561     # POST /upload — byte count
    562     dd if=/dev/urandom of="$SCRATCH/rnd" bs=1024 count=64 status=none
    563     test_method "[$label] POST /upload (64 KiB binary upload)" \
    564         POST /upload "$SCRATCH/rnd" 200 "Received 65536 bytes"
    565 
    566     # PUT /put
    567     test_method "[$label] PUT /put (PUT forwarding)" \
    568         PUT /put "$SCRATCH/post_body" 200 "PUT received"
    569 
    570     # PATCH /patch
    571     test_method "[$label] PATCH /patch (PATCH forwarding)" \
    572         PATCH /patch "$SCRATCH/post_body" 200 "PATCH received"
    573 
    574     # DELETE /item/1 with empty body
    575     msg "[$label] DELETE /item/1 (204 No Content)"
    576     local status
    577     status="$(curl -sS -X DELETE -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /item/1)" 2>"$SCRATCH/err")" \
    578         || fail "curl: $(cat "$SCRATCH/err")"
    579     [ "$status" = "204" ] || fail "status=$status"
    580     ok
    581 
    582     # OPTIONS — server should echo 204 + Allow
    583     msg "[$label] OPTIONS /anything (204 + Allow header)"
    584     local opts
    585     opts="$(curl -sS -X OPTIONS -D "$SCRATCH/hdrs" -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
    586         || fail "curl: $(cat "$SCRATCH/err")"
    587     [ "$opts" = "204" ] || fail "status=$opts"
    588     grep -qi '^allow:' "$SCRATCH/hdrs" || fail "no Allow header returned"
    589     ok
    590 
    591     # Header propagation: X-Forwarded-For must be added by paivana.
    592     msg "[$label] GET /echo-headers (X-Forwarded-For added)"
    593     curl -sS -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
    594         || fail "curl: $(cat "$SCRATCH/err")"
    595     grep -qi '^x-forwarded-for:' "$SCRATCH/body" || \
    596         fail "upstream did not see X-Forwarded-For; headers:"$'\n'"$(cat "$SCRATCH/body")"
    597     grep -qi '^x-forwarded-proto:' "$SCRATCH/body" || \
    598         fail "upstream did not see X-Forwarded-Proto"
    599     grep -qi '^via:' "$SCRATCH/body" || \
    600         fail "upstream did not see Via: paivana"
    601     ok
    602 
    603     # RFC 9110 §7.2: the Host we send upstream names the *upstream*
    604     # authority, not the one the client dialed, and carries nothing
    605     # but host[:port] — no userinfo, no path, no query.  `build_host_header`
    606     # derives it from DESTINATION_BASE_URL.
    607     msg "[$label] Host header sent upstream names the upstream authority"
    608     local want_host stripped seen_host
    609     stripped="${PAIVANA_DEST#*://}"   # drop scheme
    610     want_host="${stripped%%/*}"       # drop any path
    611     seen_host="$(grep -i '^host:' "$SCRATCH/body" | tr -d '\r' | \
    612                  sed -e 's/^[Hh][Oo][Ss][Tt]: *//')"
    613     [ -n "$seen_host" ] || \
    614         fail "upstream saw no Host header; headers:"$'\n'"$(cat "$SCRATCH/body")"
    615     [ "$seen_host" = "$want_host" ] || \
    616         fail "upstream saw 'Host: $seen_host', want 'Host: $want_host'"
    617     ok
    618 
    619     # RFC 9110 §7.6.3: client's Via chain must be preserved and our
    620     # pseudonym *appended* to it, not replaced.
    621     msg "[$label] client Via is preserved and paivana is appended"
    622     curl -sS -H 'Via: 1.1 alpha.example, 2.0 beta.example' \
    623          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
    624         || fail "curl: $(cat "$SCRATCH/err")"
    625     local via
    626     via="$(grep -i '^via:' "$SCRATCH/body" | tr -d '\r')"
    627     [ -n "$via" ] || fail "no Via header at upstream"
    628     # Expect: "Via: 1.1 alpha.example, 2.0 beta.example, 1.1 paivana"
    629     case "$via" in
    630         *"alpha.example"*"beta.example"*paivana*) ;;
    631         *) fail "Via not appended correctly: '$via'";;
    632     esac
    633     ok
    634 
    635     # RFC 9110 §7.6.1: headers named in the client's Connection
    636     # header are hop-by-hop and must not be forwarded upstream.
    637     msg "[$label] headers named in Connection: are stripped"
    638     curl -sS \
    639          -H 'Connection: X-Custom-Hop, X-Other-Hop' \
    640          -H 'X-Custom-Hop: must-not-forward' \
    641          -H 'X-Other-Hop: neither' \
    642          -H 'X-Keep: keep-this' \
    643          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
    644         || fail "curl: $(cat "$SCRATCH/err")"
    645     if grep -qi '^x-custom-hop:' "$SCRATCH/body";
    646     then
    647         fail "X-Custom-Hop leaked to upstream (Connection list ignored)"
    648     fi
    649     if grep -qi '^x-other-hop:' "$SCRATCH/body";
    650     then
    651         fail "X-Other-Hop leaked to upstream (Connection list ignored)"
    652     fi
    653     grep -qi '^x-keep:.*keep-this' "$SCRATCH/body" || \
    654         fail "X-Keep (not named in Connection) was incorrectly dropped"
    655     ok
    656 
    657     # Custom request header must be forwarded.
    658     msg "[$label] custom request header X-Test is forwarded"
    659     curl -sS -H 'X-Test: dingbat-42' \
    660          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
    661         || fail "curl: $(cat "$SCRATCH/err")"
    662     grep -qi '^x-test:.*dingbat-42' "$SCRATCH/body" || \
    663         fail "upstream did not see X-Test: dingbat-42"
    664     ok
    665 
    666     # Our access cookie is a credential for paivana itself; the
    667     # origin must never see it, while the cookies that are genuinely
    668     # the origin's have to survive verbatim.  The name match is
    669     # case-insensitive (that is how MHD looks it up) but exact: names
    670     # that merely contain it are somebody else's cookies.
    671     msg "[$label] Paivana-Cookie is stripped from the forwarded Cookie"
    672     curl -sS \
    673          -H 'Cookie: sid=alpha;paivana-cookie=1234-secret; theme=dark' \
    674          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
    675         || fail "curl: $(cat "$SCRATCH/err")"
    676     if grep -qi '^cookie:.*paivana-cookie' "$SCRATCH/body";
    677     then
    678         fail "access cookie leaked upstream: $(grep -i '^cookie:' "$SCRATCH/body")"
    679     fi
    680     grep -qi '^cookie:.*sid=alpha' "$SCRATCH/body" || \
    681         fail "client cookie sid=alpha was dropped"
    682     grep -qi '^cookie:.*theme=dark' "$SCRATCH/body" || \
    683         fail "client cookie theme=dark was dropped"
    684     ok
    685 
    686     # If the access cookie was the only one, no Cookie header at all
    687     # should reach the origin -- not an empty one.
    688     msg "[$label] lone Paivana-Cookie leaves no Cookie header"
    689     curl -sS -H 'Cookie: Paivana-Cookie=1234-secret' \
    690          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
    691         || fail "curl: $(cat "$SCRATCH/err")"
    692     if grep -qi '^cookie:' "$SCRATCH/body";
    693     then
    694         fail "unexpected Cookie header upstream: $(grep -i '^cookie:' "$SCRATCH/body")"
    695     fi
    696     ok
    697 
    698     # Cookies whose name merely embeds ours are not ours.
    699     msg "[$label] cookies named like ours are not over-stripped"
    700     curl -sS \
    701          -H 'Cookie: Paivana-Cookie-2=keep; XPaivana-Cookie=keep2' \
    702          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
    703         || fail "curl: $(cat "$SCRATCH/err")"
    704     grep -qi '^cookie:.*paivana-cookie-2=keep' "$SCRATCH/body" || \
    705         fail "Paivana-Cookie-2 was incorrectly stripped"
    706     grep -qi '^cookie:.*xpaivana-cookie=keep2' "$SCRATCH/body" || \
    707         fail "XPaivana-Cookie was incorrectly stripped"
    708     ok
    709 
    710     # RFC 9110 §7.6.1, response direction: headers named in the
    711     # *upstream's* Connection header are equally hop-by-hop and must
    712     # not be relayed to the client.  The upstream emits one such
    713     # header before the Connection line and one after it, so this
    714     # also pins that the filter is applied to the complete header
    715     # block rather than as the headers stream in.
    716     msg "[$label] headers named in upstream Connection: are stripped"
    717     curl -sS -D "$SCRATCH/hdrs" -o /dev/null \
    718          "$(PAIVANA_URL /conn-response)" 2>"$SCRATCH/err" \
    719         || fail "curl: $(cat "$SCRATCH/err")"
    720     if grep -qi '^x-hop-before:' "$SCRATCH/hdrs";
    721     then
    722         fail "X-Hop-Before leaked to client (upstream Connection ignored)"
    723     fi
    724     if grep -qi '^x-hop-after:' "$SCRATCH/hdrs";
    725     then
    726         fail "X-Hop-After leaked to client (upstream Connection ignored)"
    727     fi
    728     grep -qi '^x-keep-resp:.*survivor' "$SCRATCH/hdrs" || \
    729         fail "X-Keep-Resp (not named in Connection) was incorrectly dropped"
    730     ok
    731 
    732     # Response header passthrough: upstream sets X-Upstream.  Its
    733     # *value* is checked against the label, not merely its presence:
    734     # the point of the header is to confirm which of the four servers
    735     # answered, and every case in this battery is run against a
    736     # paivana we have just re-pointed.  A restart that silently kept
    737     # the previous destination would pass a presence check.
    738     msg "[$label] upstream response header X-Upstream is forwarded"
    739     curl -sS -D "$SCRATCH/hdrs" -o /dev/null "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err" \
    740         || fail "curl: $(cat "$SCRATCH/err")"
    741     local seen_upstream
    742     seen_upstream="$(grep -i '^x-upstream:' "$SCRATCH/hdrs" | tr -d '\r' | \
    743                      sed -e 's/^[^:]*: *//')"
    744     [ -n "$seen_upstream" ] || \
    745         fail "X-Upstream header not forwarded back to client"
    746     [ "$seen_upstream" = "$label" ] || \
    747         fail "X-Upstream='$seen_upstream', want '$label' (answered by the wrong upstream)"
    748     ok
    749 }
    750 
    751 ######################################################################
    752 # Cross-cutting tests (do not depend on which upstream is used).
    753 ######################################################################
    754 
    755 function test_method_not_allowed() {
    756     msg "unsupported HTTP method (TRACE) yields 405"
    757     local status
    758     status="$(curl -sS -X TRACE -o /dev/null -w '%{http_code}' \
    759                    "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
    760         || fail "curl: $(cat "$SCRATCH/err")"
    761     [ "$status" = "405" ] || fail "status=$status want=405"
    762     ok
    763 }
    764 
    765 function test_upload_too_big() {
    766     msg "upload exceeding 1 MiB buffer yields 413"
    767     dd if=/dev/zero of="$SCRATCH/big" bs=1024 count=2048 status=none
    768     local status
    769     status="$(curl -sS -X POST --data-binary "@$SCRATCH/big" \
    770                    -o /dev/null -w '%{http_code}' \
    771                    "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")" \
    772         || fail "curl: $(cat "$SCRATCH/err")"
    773     # 413 == Content Too Large; some builds report 500 on hook close
    774     [ "$status" = "413" ] || fail "status=$status want=413"
    775     ok
    776 }
    777 
    778 function test_upload_too_big_early() {
    779     # Open a raw TCP connection and send a POST whose Content-Length
    780     # already exceeds the buffer cap, but DON'T send any body bytes.
    781     # Paivana must reject on the Content-Length header alone (during
    782     # MHD's HEADERS_PROCESSED callback), respond with 413 and close
    783     # the connection.  If the early-reject path is missing the server
    784     # would block waiting for a body that never arrives and we'd hit
    785     # the timeout, which fails the test rather than masquerading as
    786     # a pass.
    787     msg "Content-Length exceeding 1 MiB triggers early 413 (no body sent)"
    788     local out
    789     out="$( ( exec 3<>"/dev/tcp/127.0.0.1/$PAIVANA_PORT"
    790               printf 'POST /upload HTTP/1.1\r\nHost: 127.0.0.1:%s\r\nContent-Length: 10485760\r\nConnection: close\r\n\r\n' \
    791                      "$PAIVANA_PORT" >&3
    792               timeout 5 cat <&3 ) 2>"$SCRATCH/err")" \
    793         || fail "raw POST failed (timeout or socket error): $(cat "$SCRATCH/err")"
    794     case "$out" in
    795         'HTTP/1.1 413'*) ;;
    796         *) fail "expected 413 status line, got: $(echo "$out" | head -c 80)";;
    797     esac
    798     ok
    799 }
    800 
    801 function test_upload_too_big_no_continue() {
    802     # When the client opts in to 100-continue, paivana must NOT send
    803     # the interim 100 response if it has already decided to reject
    804     # the upload — it should jump straight to 413.  Use curl with
    805     # `Expect: 100-continue` and verbose tracing, then assert that
    806     # no `< HTTP/1.1 100` line appeared on the wire.
    807     msg "rejection suppresses 100 Continue when client opts in"
    808     dd if=/dev/zero of="$SCRATCH/big" bs=1024 count=2048 status=none
    809     local status
    810     status="$(curl -sSv -X POST -H 'Expect: 100-continue' \
    811                    --expect100-timeout 5 \
    812                    --data-binary "@$SCRATCH/big" \
    813                    -o /dev/null -w '%{http_code}' \
    814                    "$(PAIVANA_URL /upload)" 2>"$SCRATCH/trace")" \
    815         || fail "curl: $(cat "$SCRATCH/trace")"
    816     [ "$status" = "413" ] || fail "status=$status want=413"
    817     if grep -q '^< HTTP/1.1 100' "$SCRATCH/trace";
    818     then
    819         fail "server sent 100 Continue before 413; trace: $(grep '^<' "$SCRATCH/trace")"
    820     fi
    821     ok
    822 }
    823 
    824 function test_upload_too_big_chunked() {
    825     # Chunked transfer-encoding has no Content-Length, so paivana
    826     # cannot know the upload is too big until it actually reaches
    827     # the cap mid-stream.  This test guards the fallback
    828     # drain-then-reject path that runs in BODY_RECEIVING /
    829     # FULL_REQ_RECEIVED.
    830     msg "chunked upload exceeding 1 MiB still yields 413 (drain path)"
    831     dd if=/dev/zero of="$SCRATCH/big" bs=1024 count=2048 status=none
    832     local status
    833     status="$(curl -sS -X POST \
    834                    -H 'Transfer-Encoding: chunked' \
    835                    -H 'Content-Length:' \
    836                    --data-binary "@$SCRATCH/big" \
    837                    -o /dev/null -w '%{http_code}' \
    838                    "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")" \
    839         || fail "curl: $(cat "$SCRATCH/err")"
    840     [ "$status" = "413" ] || fail "status=$status want=413"
    841     ok
    842 }
    843 
    844 # 768 KiB — under paivana's 1 MiB request-buffer cap, but far too
    845 # large to fit in kernel TCP buffers, so the upstream is guaranteed
    846 # to have answered while paivana's curl is still uploading.
    847 EARLY_PAYLOAD_SIZE=786432
    848 
    849 # Write the early-response payload to $SCRATCH/early_body, once.
    850 function make_early_payload() {
    851     local payload="$SCRATCH/early_body"
    852     local got
    853     [ -s "$payload" ] && return 0
    854     dd if=/dev/urandom of="$payload" bs=1024 count=768 status=none
    855     got="$(wc -c <"$payload" | tr -d ' ')"
    856     [ "$got" = "$EARLY_PAYLOAD_SIZE" ] || \
    857         fail "test setup: payload is $got bytes, want $EARLY_PAYLOAD_SIZE"
    858 }
    859 
    860 EARLY_PID=""
    861 
    862 # Start early_response_upstream.  $1 = port, $2 = receipt file, and
    863 # any further arguments go to the upstream (e.g. --no-drain).
    864 function start_early_upstream() {
    865     local port="$1" receipt="$2"; shift 2
    866     rm -f "$receipt"
    867     ( exec "$EARLY_RESPONSE_UPSTREAM" "$port" "$receipt" "$@" ) \
    868         >"$LOGDIR/early-$port.log" 2>&1 &
    869     EARLY_PID=$!
    870     PIDS+=("$EARLY_PID")
    871     if ! wait_for_port 127.0.0.1 "$port" "$EARLY_PID";
    872     then
    873         fail "early_response_upstream did not start on port $port"
    874     fi
    875 }
    876 
    877 function stop_early_upstream() {
    878     if [ -n "$EARLY_PID" ];
    879     then
    880         kill -TERM "$EARLY_PID" 2>/dev/null
    881         # In --no-drain mode the upstream may be parked on a
    882         # connection whose peer never hangs up, so back the TERM with
    883         # a KILL after two seconds: a wedged helper must not wedge the
    884         # whole suite.
    885         #
    886         # Polled here rather than armed as `( sleep 2; kill -KILL ) &'
    887         # and cancelled afterwards.  That subshell inherits this
    888         # script's EXIT trap, and the `kill -TERM' that cancels it
    889         # makes bash run that trap *in the subshell*: cleanup() then
    890         # rm -rf's the scratch directory and TERMs every helper while
    891         # the suite is still running, and the run dies several checks
    892         # later reading "logs/early-<port>.log: No such file or
    893         # directory".  Reproduced in isolation, and seen once in
    894         # roughly twenty runs of the suite -- only when the wait
    895         # really does take the full two seconds, which is the case
    896         # the watchdog exists for.
    897         local tries=20
    898         while [ "$tries" -gt 0 ] && kill -0 "$EARLY_PID" 2>/dev/null;
    899         do
    900             sleep 0.1
    901             tries=$((tries - 1))
    902         done
    903         kill -KILL "$EARLY_PID" 2>/dev/null
    904         wait "$EARLY_PID" 2>/dev/null
    905         EARLY_PID=""
    906     fi
    907 }
    908 
    909 # Block until the upstream's receipt file appears (it writes it once
    910 # the connection is over), then echo the count it recorded.  Echoes
    911 # nothing if it never showed up.
    912 function read_receipt() {
    913     local receipt="$1" tries=50
    914     while [ "$tries" -gt 0 ] && [ ! -s "$receipt" ];
    915     do
    916         sleep 0.1
    917         tries=$((tries - 1))
    918     done
    919     [ -s "$receipt" ] || return 0
    920     tr -d '\n ' <"$receipt"
    921 }
    922 
    923 function test_early_response() {
    924     # The upstream sends a 413 response immediately after reading the
    925     # request headers, before consuming any of the request body — a
    926     # legal HTTP/1.1 pattern.  Paivana must deliver that response back
    927     # to its own client rather than turning it into a 502, even though
    928     # its curl handle was still uploading when it arrived.
    929     #
    930     # Note what is deliberately NOT asserted: that the upstream sees
    931     # the whole body.  RFC 9110 §9.3 lets a client stop sending once
    932     # it has a final response, and libcurl does exactly that — plain
    933     # `curl` against this upstream sends ~128 KiB of a 768 KiB body
    934     # and stops.  The receipt the upstream writes is therefore a
    935     # diagnostic, and all we require of it is that it appears: that
    936     # means the exchange finished upstream-side instead of leaving
    937     # the upstream blocked in read() forever.
    938     msg "early upstream response is forwarded to the client"
    939     if [ ! -x "$EARLY_RESPONSE_UPSTREAM" ];
    940     then
    941         echo "SKIP (early_response_upstream not built)"
    942         return
    943     fi
    944     stop_paivana
    945     local receipt="$SCRATCH/early_receipt"
    946     start_early_upstream "$EARLY_PORT" "$receipt"
    947     start_paivana "http://127.0.0.1:$EARLY_PORT"
    948     make_early_payload
    949 
    950     # Without the early-response fix paivana would abort the curl
    951     # handle and answer 502 instead of relaying what it was handed.
    952     local status
    953     status="$(curl -sS -X POST --data-binary "@$SCRATCH/early_body" \
    954                    --max-time 30 \
    955                    -o "$SCRATCH/body" -w '%{http_code}' \
    956                    "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")" \
    957         || fail "curl: $(cat "$SCRATCH/err")"
    958     [ "$status" = "413" ] || \
    959         fail "status=$status want=413 (a 502 here means paivana aborted the forward instead of relaying the early response)"
    960     grep -q 'early-response-payload' "$SCRATCH/body" || \
    961         fail "response body did not come from upstream: $(head -c 200 "$SCRATCH/body")"
    962 
    963     [ -n "$(read_receipt "$receipt")" ] || \
    964         fail "upstream never finished the exchange (still blocked reading the body?)"
    965     ok
    966 
    967     stop_paivana
    968     stop_early_upstream
    969 }
    970 
    971 function test_early_response_no_drain() {
    972     # The dangerous half of the same situation.  Here the upstream
    973     # answers early and then refuses to read another byte, leaving
    974     # the rest of the request queued in its (deliberately tiny)
    975     # receive buffer.  Paivana's outbound socket therefore stays full
    976     # and its write() can never complete: the only way out is to act
    977     # on the response it already holds.  A proxy that instead insists
    978     # on finishing the upload first deadlocks until its own transfer
    979     # timeout (60s for us) and only then answers 502 — which is why
    980     # `timeout` bounds this case.  Without that bound the hang would
    981     # eventually "pass" as a slow 502 rather than fail.
    982     #
    983     # The drain-mode test above cannot catch this: because that
    984     # upstream keeps read()-ing until EOF, paivana's socket never
    985     # stays full and the deadlock never has a chance to form.
    986     msg "early upstream response, upstream then stops reading: no hang"
    987     if [ ! -x "$EARLY_RESPONSE_UPSTREAM" ];
    988     then
    989         echo "SKIP (early_response_upstream not built)"
    990         return
    991     fi
    992     stop_paivana
    993     local receipt="$SCRATCH/nodrain_receipt"
    994     start_early_upstream "$NODRAIN_PORT" "$receipt" --no-drain
    995     start_paivana "http://127.0.0.1:$NODRAIN_PORT"
    996     make_early_payload
    997 
    998     # 20s is comfortably above a healthy round-trip (milliseconds)
    999     # and comfortably below paivana's 60s CURLOPT_TIMEOUT, so a
   1000     # deadlock shows up as a killed curl rather than a late answer.
   1001     local status rc
   1002     status="$(timeout 20 curl -sS -X POST \
   1003                       --data-binary "@$SCRATCH/early_body" \
   1004                       -o "$SCRATCH/body" -w '%{http_code}' \
   1005                       "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")"
   1006     rc=$?
   1007     [ "$rc" != "124" ] && [ "$rc" != "137" ] || \
   1008         fail "no response within 20s: paivana blocked on an upload the upstream stopped reading"
   1009     [ "$rc" = "0" ] || fail "curl exited $rc: $(cat "$SCRATCH/err")"
   1010     [ "$status" = "413" ] || \
   1011         fail "status=$status want=413 (a 502 means paivana gave up on the transfer instead of using the response it had)"
   1012     grep -q 'early-response-payload' "$SCRATCH/body" || \
   1013         fail "response body did not come from upstream: $(head -c 200 "$SCRATCH/body")"
   1014     # No receipt assertion here: the upstream only ever saw whatever
   1015     # fit in its receive buffer, and whether the connection is closed
   1016     # or kept for reuse afterwards is paivana's business.  The count
   1017     # it does eventually record is for diagnostics.
   1018     ok
   1019 
   1020     stop_paivana
   1021     stop_early_upstream
   1022 }
   1023 
   1024 
   1025 function start_truncating_upstream() {
   1026     # An upstream that declares more body than it delivers and then
   1027     # closes: the wire shape of a response that arrived incomplete.
   1028     # It is also exactly what paivana ends up holding when
   1029     # libgnunetcurl stops buffering at its 40 MiB ceiling -- libcurl
   1030     # fails the transfer, but the status line it parsed long before is
   1031     # still what CURLINFO_RESPONSE_CODE reports.
   1032     local port="$1"
   1033     cat >"$SCRATCH/truncating_upstream.py" <<'PYEOF'
   1034 import socket
   1035 import sys
   1036 
   1037 port = int(sys.argv[1])
   1038 srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
   1039 srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
   1040 srv.bind(("127.0.0.1", port))
   1041 srv.listen(5)
   1042 while True:
   1043     conn, _ = srv.accept()
   1044     try:
   1045         conn.recv(65536)
   1046         conn.sendall(b"HTTP/1.1 200 OK\r\n"
   1047                      b"Content-Type: text/plain\r\n"
   1048                      b"Content-Length: 100000\r\n"
   1049                      b"\r\n"
   1050                      + b"x" * 1000)
   1051     except OSError:
   1052         pass
   1053     conn.close()
   1054 PYEOF
   1055     local log="$LOGDIR/truncating.log"
   1056     ( exec python3 "$SCRATCH/truncating_upstream.py" "$port" ) >"$log" 2>&1 &
   1057     local tpid=$!
   1058     PIDS+=("$tpid")
   1059     if ! wait_for_port 127.0.0.1 "$port" "$tpid";
   1060     then
   1061         echo "FAIL: truncating upstream did not start on port $port" >&2
   1062         tail -n 20 "$log" >&2
   1063         exit 1
   1064     fi
   1065 }
   1066 
   1067 # ======================================================================
   1068 # Streaming
   1069 #
   1070 # The point of the whole streaming path is that a proxied body is no
   1071 # longer bounded by memory, and that it starts reaching the client
   1072 # before the origin has finished sending.  Neither is visible in the
   1073 # battery above, where every body fits in one buffer and would have
   1074 # done so before.
   1075 #
   1076 # `stream_upstream' serves bodies generated from their own offset, and
   1077 # `stream_client' verifies them the same way as they arrive, so a
   1078 # 200 MiB case costs no disk on either side and a duplicated or dropped
   1079 # block is caught rather than just a wrong total.  The client prints
   1080 # `key=value' lines; `sfield' pulls one out.
   1081 # ======================================================================
   1082 
   1083 # Bytes used for the "large" cases.  200 MiB is five times the 40 MiB
   1084 # ceiling that used to make these a 502, which is the point.
   1085 #
   1086 # PAIVANA_TEST_SCALE divides it, for the sanitised build where 200 MiB
   1087 # is a coffee break rather than a test.  Dividing is sound here because
   1088 # what these cases exercise is the pause/resume interleaving, and that
   1089 # is a function of the ring size -- which does not scale -- rather than
   1090 # of the total: a tenth of the bytes still crosses the ring hundreds of
   1091 # times.  It is not sound for the *bound* being checked, so the 413
   1092 # cases and the buffer sizes are left alone.
   1093 STREAM_BIG=$(( (200 * 1024 * 1024) / ${PAIVANA_TEST_SCALE:-1} ))
   1094 
   1095 function start_stream_upstream() {
   1096     local port="$1"
   1097     local log="$LOGDIR/stream.log"
   1098 
   1099     ( exec "$BUILDDIR/stream_upstream" "$port" ) >"$log" 2>&1 &
   1100     local spid=$!
   1101     PIDS+=("$spid")
   1102     if ! wait_for_port 127.0.0.1 "$port" "$spid";
   1103     then
   1104         echo "FAIL: stream upstream did not start on port $port" >&2
   1105         tail -n 20 "$log" >&2
   1106         exit 1
   1107     fi
   1108 }
   1109 
   1110 # Run stream_client against the streaming upstream through paivana and
   1111 # leave its report in $SCRATCH/sc.  Never fails the test itself: which
   1112 # way a transfer went wrong is what the cases assert on.
   1113 function sc() {
   1114     local path="$1"; shift
   1115 
   1116     if ! timeout 300 "$BUILDDIR/stream_client" \
   1117          "$(PAIVANA_URL "$path")" "$@" > "$SCRATCH/sc" 2>"$SCRATCH/scerr";
   1118     then
   1119         fail "stream_client did not finish for $path: $(cat "$SCRATCH/scerr")"
   1120     fi
   1121 }
   1122 
   1123 # Value of one `key=value' line of the last sc() report.
   1124 function sfield() {
   1125     sed -n "s/^$1=//p" "$SCRATCH/sc"
   1126 }
   1127 
   1128 # Assert that a field of the last sc() report has the expected value.
   1129 function sc_is() {
   1130     local key="$1" want="$2" got
   1131     got="$(sfield "$key")"
   1132     [ "$got" = "$want" ] || \
   1133         fail "$key=$got want=$want ($(tr '\n' ' ' < "$SCRATCH/sc"))"
   1134 }
   1135 
   1136 function test_streaming() {
   1137     stop_paivana
   1138     start_stream_upstream "$STREAM_PORT"
   1139     # A short stall timeout so the "origin goes quiet" cases cost
   1140     # seconds rather than the minute the shipped default allows, and a
   1141     # request cap above the large upload cases.
   1142     local cfg="$SCRATCH/paivana-stream.conf"
   1143     sed -e "s|@DEST@|http://127.0.0.1:$STREAM_PORT|g" \
   1144         -e "s|@PORT@|$PAIVANA_PORT|g" \
   1145         "$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
   1146     cat >> "$cfg" <<EOF
   1147 UPSTREAM_TIMEOUT = 3 s
   1148 UPSTREAM_STALL_TIMEOUT = 3 s
   1149 MAX_REQUEST_SIZE = $((512 * 1024 * 1024))
   1150 EOF
   1151     PAIVANA_DEST="http://127.0.0.1:$STREAM_PORT"
   1152     local log="$LOGDIR/paivana.log"
   1153     ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING ) >"$log" 2>&1 &
   1154     PAIVANA_PID=$!
   1155     if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT" "$PAIVANA_PID";
   1156     then
   1157         echo "FAIL: paivana-httpd did not start on port $PAIVANA_PORT" >&2
   1158         tail -n 20 "$log" >&2
   1159         exit 1
   1160     fi
   1161 
   1162     # --- 1: a body five times the old ceiling, with a length --------
   1163     msg "streams a 200 MiB response with Content-Length"
   1164     sc "/cl?bytes=$STREAM_BIG" --expect-bytes "$STREAM_BIG"
   1165     sc_is status 200
   1166     sc_is curl 0
   1167     sc_is pattern ok
   1168     sc_is bytes "$STREAM_BIG"
   1169     # The origin's own framing, not one recomputed from a buffer.
   1170     sc_is content_length "$STREAM_BIG"
   1171     sc_is chunked no
   1172     ok
   1173 
   1174     # --- 2: the same body, chunked ----------------------------------
   1175     msg "streams a 200 MiB chunked response, still chunked"
   1176     sc "/chunked?bytes=$STREAM_BIG" --expect-bytes "$STREAM_BIG"
   1177     sc_is status 200
   1178     sc_is curl 0
   1179     sc_is pattern ok
   1180     sc_is bytes "$STREAM_BIG"
   1181     # A chunked origin must not be silently converted to a declared
   1182     # length, which is what buffering the body did.
   1183     sc_is chunked yes
   1184     sc_is content_length none
   1185     ok
   1186 
   1187     # --- 3: chunked origin, HTTP/1.0 client -------------------------
   1188     # An HTTP/1.0 client cannot be sent chunks, so the end of the body
   1189     # has to be the close of the connection.
   1190     msg "chunked upstream is close-delimited for an HTTP/1.0 client"
   1191     local out
   1192     out="$(curl -sS --http1.0 -o "$SCRATCH/h10" -D "$SCRATCH/h10hdr" \
   1193                 -w '%{http_code}' --max-time 120 \
   1194                 "$(PAIVANA_URL "/chunked?bytes=1048576")" 2>"$SCRATCH/err")" \
   1195         || fail "curl: $(cat "$SCRATCH/err")"
   1196     [ "$out" = "200" ] || fail "status=$out want=200"
   1197     grep -qi '^Transfer-Encoding:' "$SCRATCH/h10hdr" && \
   1198         fail "chunked encoding offered to an HTTP/1.0 client"
   1199     [ "$(wc -c < "$SCRATCH/h10")" = "1048576" ] || \
   1200         fail "got $(wc -c < "$SCRATCH/h10") bytes, want 1048576"
   1201     ok
   1202 
   1203     # --- 4: a range request through the stream --------------------
   1204     msg "206 and Content-Range pass through a streamed response"
   1205     out="$(curl -sS -r 1000-1999 -o "$SCRATCH/rng" -D "$SCRATCH/rnghdr" \
   1206                 -w '%{http_code}' --max-time 60 \
   1207                 "$(PAIVANA_URL "/range?bytes=1048576")" 2>"$SCRATCH/err")" \
   1208         || fail "curl: $(cat "$SCRATCH/err")"
   1209     [ "$out" = "206" ] || fail "status=$out want=206"
   1210     grep -qi '^Content-Range: bytes 1000-1999/1048576' "$SCRATCH/rnghdr" || \
   1211         fail "no matching Content-Range: $(grep -i content-range "$SCRATCH/rnghdr")"
   1212     [ "$(wc -c < "$SCRATCH/rng")" = "1000" ] || \
   1213         fail "got $(wc -c < "$SCRATCH/rng") bytes, want 1000"
   1214     ok
   1215 
   1216     # --- 5: HEAD on a large resource --------------------------------
   1217     # MHD does not run the content reader for a HEAD but does emit the
   1218     # size the response was created with, so the length the equivalent
   1219     # GET would have had now reaches the client (RFC 9110 9.3.2).
   1220     # Buffering could only ever have reported 0 here.
   1221     msg "HEAD reports the upstream's length without a body"
   1222     sc "/cl?bytes=$STREAM_BIG" --head
   1223     sc_is status 200
   1224     sc_is bytes 0
   1225     sc_is content_length "$STREAM_BIG"
   1226     ok
   1227 
   1228     # --- 6: statuses that carry no body -----------------------------
   1229     msg "204 carries no body and no length"
   1230     sc "/status?code=204" --print-body
   1231     sc_is status 204
   1232     sc_is bytes 0
   1233     sc_is content_length none
   1234     ok
   1235 
   1236     msg "304 keeps the length of the body it does not send"
   1237     sc "/status?code=304&len=12345" --print-body
   1238     sc_is status 304
   1239     sc_is bytes 0
   1240     sc_is content_length 12345
   1241     ok
   1242 
   1243     # --- 7-8: the upload direction ----------------------------------
   1244     # The origin reports what it received; `pattern=ok' in its report
   1245     # is the byte-exactness assertion, and `framing=' is the assertion
   1246     # that the client's own framing was reproduced upstream rather
   1247     # than rewritten.
   1248     msg "streams a 200 MiB request body with Content-Length"
   1249     sc "/sink" --upload "$STREAM_BIG" --print-body
   1250     sc_is status 200
   1251     case "$(sfield body)" in
   1252         "bytes=$STREAM_BIG framing=length pattern=ok") ;;
   1253         *) fail "upstream saw: $(sfield body)" ;;
   1254     esac
   1255     ok
   1256 
   1257     msg "streams a 200 MiB chunked request body, still chunked"
   1258     sc "/sink" --upload "$STREAM_BIG" --chunked-upload --print-body
   1259     sc_is status 200
   1260     case "$(sfield body)" in
   1261         "bytes=$STREAM_BIG framing=chunked pattern=ok") ;;
   1262         *) fail "upstream saw: $(sfield body)" ;;
   1263     esac
   1264     ok
   1265 
   1266     # --- 9: the common case, which now takes the same path ----------
   1267     msg "a small POST still round-trips"
   1268     sc "/sink" --upload 100 --print-body
   1269     sc_is status 200
   1270     case "$(sfield body)" in
   1271         "bytes=100 framing=length pattern=ok") ;;
   1272         *) fail "upstream saw: $(sfield body)" ;;
   1273     esac
   1274     ok
   1275 
   1276     # --- 18: chunked response with no terminating chunk -------------
   1277     # The status is long gone by the time the origin gives up, so the
   1278     # only remaining way to say "this is incomplete" is to close
   1279     # without the terminator.  curl 18 is the client noticing.
   1280     msg "a chunked upstream that stops mid-stream truncates the client"
   1281     sc "/chunk-abort?after=5000"
   1282     sc_is status 200
   1283     sc_is chunked yes
   1284     sc_is bytes 5000
   1285     sc_is curl 18
   1286     ok
   1287 
   1288     # --- 19: the stall watchdog -------------------------------------
   1289     # An origin that sends headers and some body and then goes quiet
   1290     # for ever.  MHD will not time this out -- a suspended connection
   1291     # is off its timeout lists -- and CURLOPT_TIMEOUT is deliberately
   1292     # unset, so paivana's own watchdog is the only thing that can end
   1293     # it.  Without it the client hangs until it gives up itself.
   1294     msg "an upstream that goes quiet is cut off by the stall watchdog"
   1295     local t0 t1
   1296     t0="$(date +%s)"
   1297     sc "/hang?after=1000"
   1298     t1="$(date +%s)"
   1299     sc_is status 200
   1300     sc_is bytes 1000
   1301     sc_is curl 18
   1302     [ "$((t1 - t0))" -lt 30 ] || \
   1303         fail "took $((t1 - t0))s; the 3 s stall timeout did not fire"
   1304     grep -q "moved no data" "$log" || \
   1305         fail "no stall diagnostic in the log"
   1306     ok
   1307 
   1308     # --- 20: an upstream that accepts and never answers -------------
   1309     # Distinct from an upstream that is not there (502, tested
   1310     # separately): this one is a 504, and the time-to-headers clock is
   1311     # what tells them apart.  It is the only one of the three clocks
   1312     # that can still produce a status code.
   1313     msg "an upstream that never answers yields 504"
   1314     out="$(curl -sS -o "$SCRATCH/body" -w '%{http_code}' --max-time 60 \
   1315                 "$(PAIVANA_URL /mute)" 2>"$SCRATCH/err")" \
   1316         || fail "curl: $(cat "$SCRATCH/err")"
   1317     [ "$out" = "504" ] || fail "status=$out want=504"
   1318     ok
   1319 
   1320     # --- 21: the client walks away mid-download ---------------------
   1321     # The interesting part is not the one request but that a hundred of
   1322     # them leave nothing behind: the response is queued and its content
   1323     # reader is live for every one of these, so a mistake in the
   1324     # ownership handshake between MHD's completion notifier and the
   1325     # reader's free callback leaks (or worse) on each.
   1326     msg "100 downloads abandoned mid-body leave no growth behind"
   1327     local rss0 rss1
   1328     sc "/cl?bytes=$STREAM_BIG" --abort-after 1048576
   1329     rss0="$(awk '/VmRSS/{print $2}' "/proc/$PAIVANA_PID/status")"
   1330     local i=0
   1331     while [ "$i" -lt 100 ];
   1332     do
   1333         timeout 60 "$BUILDDIR/stream_client" \
   1334                 "$(PAIVANA_URL "/cl?bytes=$STREAM_BIG")" \
   1335                 --abort-after 1048576 >/dev/null 2>&1 || \
   1336             fail "stream_client did not finish on iteration $i"
   1337         i=$((i + 1))
   1338     done
   1339     rss1="$(awk '/VmRSS/{print $2}' "/proc/$PAIVANA_PID/status")"
   1340     if [ -n "${PAIVANA_SANITIZED:-}" ];
   1341     then
   1342         # RSS is not a leak detector under ASan: redzones around every
   1343         # allocation and a quarantine that deliberately withholds freed
   1344         # memory make the process grow whether or not anything leaked.
   1345         # The hundred iterations above still ran, and LSan is watching
   1346         # them -- which is a far better detector than this bound.  It
   1347         # is this bound that is the stand-in, for the build where LSan
   1348         # is not there.
   1349         echo "OK (RSS bound not meaningful under sanitizers; LSan covers it)"
   1350     else
   1351         # A generous bound: the point is "flat", not "identical".  Real
   1352         # per-request leakage of a ring or a response would be megabytes
   1353         # over a hundred iterations.
   1354         [ "$((rss1 - rss0))" -lt 4096 ] || \
   1355             fail "RSS grew ${rss0}k -> ${rss1}k over 100 abandoned downloads"
   1356         ok
   1357     fi
   1358 
   1359     # --- 22: the client walks away mid-upload -----------------------
   1360     # We have declared a Content-Length upstream that we can no longer
   1361     # deliver, so the origin has to be told the request is broken
   1362     # rather than left waiting for bytes that will never come.
   1363     msg "an upload abandoned by the client does not wedge paivana"
   1364     timeout 60 curl -sS -o /dev/null --max-time 2 \
   1365             --data-binary "@$SCRATCH/echo_big" \
   1366             "$(PAIVANA_URL "/sink?rate=20000")" >/dev/null 2>&1
   1367     # Whatever that did to the one request, the daemon must still be
   1368     # serving; a wedged read callback would take the event loop with
   1369     # it.
   1370     sc "/cl?bytes=1024" --expect-bytes 1024
   1371     sc_is status 200
   1372     sc_is pattern ok
   1373     ok
   1374 
   1375     # --- 23: the origin answers during a large upload ---------------
   1376     # Only reachable because the request body is streamed: with it
   1377     # buffered first, the origin could not have answered before seeing
   1378     # all of it.  The client must get the origin's 413, not a 502, and
   1379     # the drain must complete rather than deadlock.
   1380     msg "an early 413 during a 200 MiB upload reaches the client"
   1381     sc "/sink-early?after=1048576" --upload "$STREAM_BIG" --print-body
   1382     sc_is status 413
   1383     ok
   1384 
   1385     # --- 24-25: what 0034 established, on the streamed path ---------
   1386     msg "trailers on a streamed chunked response are still dropped"
   1387     out="$(curl -sS -o "$SCRATCH/tr" -D "$SCRATCH/trhdr" \
   1388                 -w '%{http_code}' --max-time 60 \
   1389                 "$(PAIVANA_URL "/trailers?bytes=4096")" 2>"$SCRATCH/err")" \
   1390         || fail "curl: $(cat "$SCRATCH/err")"
   1391     [ "$out" = "200" ] || fail "status=$out want=200"
   1392     grep -qi 'X-Trailer-Check' "$SCRATCH/trhdr" && \
   1393         fail "a trailer field was merged into the header section"
   1394     ok
   1395 
   1396     msg "a 1xx before a streamed response is not merged into it"
   1397     out="$(curl -sS -o "$SCRATCH/ih" -D "$SCRATCH/ihhdr" \
   1398                 -w '%{http_code}' --max-time 60 \
   1399                 "$(PAIVANA_URL "/interim?bytes=4096")" 2>"$SCRATCH/err")" \
   1400         || fail "curl: $(cat "$SCRATCH/err")"
   1401     [ "$out" = "200" ] || fail "status=$out want=200"
   1402     grep -qi 'X-Interim-Check' "$SCRATCH/ihhdr" && \
   1403         fail "an interim-response header reappeared on the final response"
   1404     [ "$(wc -c < "$SCRATCH/ih")" = "4096" ] || \
   1405         fail "got $(wc -c < "$SCRATCH/ih") bytes, want 4096"
   1406     ok
   1407 
   1408     stop_paivana
   1409 }
   1410 
   1411 
   1412 # ======================================================================
   1413 # Congestion, and the bound being real
   1414 #
   1415 # The cases in test_streaming show that a large body gets through
   1416 # intact.  They do not show that it got through *without being held in
   1417 # memory*, and they would all pass just as well against a version that
   1418 # quietly buffered the lot -- so on their own the central claim of the
   1419 # whole change is untested.  These are the cases that test it.
   1420 #
   1421 # Three things are measured that the client cannot see on its own:
   1422 #
   1423 #   - paivana's VmRSS while a large body is in flight, which is the
   1424 #     bound itself;
   1425 #   - how long the *origin* took to write its body, which is the
   1426 #     backpressure.  A proxy that buffers takes everything at line rate
   1427 #     however slowly its client reads; one that relays can only take
   1428 #     what the client has made room for.  From the client end the two
   1429 #     look identical, which is why the origin reports its own timing;
   1430 #   - paivana's CPU time across an interval when nothing is moving,
   1431 #     which is the busy-wait detector.  Spinning is the classic failure
   1432 #     of a suspend/resume design and is otherwise invisible: the
   1433 #     transfer still completes, just with a core pinned.
   1434 #
   1435 # Rate limits are what make any of this reproducible.  On loopback with
   1436 # both ends going flat out, the kernel socket buffers absorb everything
   1437 # and no ring ever fills.
   1438 # ======================================================================
   1439 
   1440 # Sizes for these cases, in bytes.  Smaller than test_streaming's,
   1441 # because each is deliberately slowed to a few seconds and the point
   1442 # here is the shape of the flow rather than the total.
   1443 #
   1444 # Deliberately NOT divided by PAIVANA_TEST_SCALE, unlike test_streaming.
   1445 # Every one of these is rate-limited, so its duration is set by the rate
   1446 # and not by the size, and the sanitised build is no slower for them.
   1447 # Scaling them down would also break the pacing assertions outright: the
   1448 # kernel socket buffers hold a fixed couple of megabytes however small
   1449 # the body is, so at a twentieth of the size the origin legitimately
   1450 # finishes well ahead of the client and "was it throttled" stops having
   1451 # a stable answer.
   1452 CONG_BIG=$((64 * 1024 * 1024))
   1453 CONG_MID=$((32 * 1024 * 1024))
   1454 CONG_SMALL=$((8 * 1024 * 1024))
   1455 
   1456 # Resident set of a process, in kB.
   1457 function rss_kb() {
   1458     awk '/VmRSS/{print $2}' "/proc/$1/status" 2>/dev/null || echo 0
   1459 }
   1460 
   1461 # User+system CPU of a process, in jiffies (100 per second).
   1462 function cpu_jiffies() {
   1463     awk '{print $14 + $15}' "/proc/$1/stat" 2>/dev/null || echo 0
   1464 }
   1465 
   1466 # Sample rss_kb of $1 every 200 ms until none of the pids in $2.. are
   1467 # left, leaving the maximum in $PEAK_RSS and the number of samples
   1468 # taken in $RSS_SAMPLES.
   1469 #
   1470 # Deliberately given the pids to wait for rather than asking `jobs':
   1471 # paivana itself is a background job of this same shell, so "wait while
   1472 # any job is running" never becomes false and the sampler spins for
   1473 # ever.  For the same reason callers must `wait' on the client pids by
   1474 # name and not bare.
   1475 #
   1476 # The sample count is not bookkeeping.  If the transfer finishes before
   1477 # the first tick, the loop never runs, the peak is whatever RSS was
   1478 # before it started, and "no growth" is asserted about a measurement
   1479 # that was never taken -- a silent vacuous pass on the one claim the
   1480 # whole change rests on.  Callers must check it.
   1481 function watch_rss() {
   1482     local pid="$1"; shift
   1483     local r p alive
   1484     PEAK_RSS="$(rss_kb "$pid")"
   1485     RSS_SAMPLES=0
   1486     while true;
   1487     do
   1488         alive=0
   1489         for p in "$@";
   1490         do
   1491             if kill -0 "$p" 2>/dev/null;
   1492             then
   1493                 alive=1
   1494                 break
   1495             fi
   1496         done
   1497         [ "$alive" = "0" ] && break
   1498         r="$(rss_kb "$pid")"
   1499         [ "${r:-0}" -gt "$PEAK_RSS" ] && PEAK_RSS="$r"
   1500         RSS_SAMPLES=$((RSS_SAMPLES + 1))
   1501         sleep 0.2
   1502     done
   1503 }
   1504 
   1505 # Assert $PEAK_RSS is no more than $2 kB above the baseline $1, and
   1506 # report the measurement; $3 describes the transfer for the failure.
   1507 #
   1508 # Skipped under sanitizers, where RSS stops meaning what this case
   1509 # needs it to mean.  ASan surrounds every allocation with redzones and,
   1510 # more to the point, holds freed chunks in a quarantine rather than
   1511 # reusing them -- that quarantine is exactly what lets it catch a
   1512 # use-after-free, so turning it down to make this number readable would
   1513 # trade away the thing the sanitised build exists for.  Measured: the
   1514 # 64 MiB case grows ~58 MB instrumented against ~0.5 MB not, for
   1515 # identical code.  LSan is the detector in that build; this is its
   1516 # stand-in in the ordinary one.
   1517 #
   1518 # Printing the number rather than just "OK" is deliberate: a bound that
   1519 # is never approached and a bound that was never measured look the same
   1520 # from a pass, and this is the assertion the whole change rests on.
   1521 function rss_bound() {
   1522     local base="$1" limit="$2" what="$3"
   1523 
   1524     if [ -n "${PAIVANA_SANITIZED:-}" ];
   1525     then
   1526         echo "OK (RSS bound not meaningful under sanitizers;" \
   1527              "saw +$((PEAK_RSS - base))k, LSan covers the leak side)"
   1528         return
   1529     fi
   1530     [ "$((PEAK_RSS - base))" -lt "$limit" ] || \
   1531         fail "RSS grew ${base}k -> ${PEAK_RSS}k $what"
   1532     echo "OK (peak +$((PEAK_RSS - base))k over $RSS_SAMPLES samples)"
   1533 }
   1534 
   1535 # ms the origin reported for the most recent request whose target
   1536 # matches $1.  See the "served" line stream_upstream writes per
   1537 # connection.
   1538 function origin_ms() {
   1539     tr -d '\0' < "$LOGDIR/stream.log" \
   1540         | grep -a "^served target=$1 " | tail -1 \
   1541         | sed -n 's/.* ms=//p'
   1542 }
   1543 
   1544 function origin_bytes() {
   1545     tr -d '\0' < "$LOGDIR/stream.log" \
   1546         | grep -a "^served target=$1 " | tail -1 \
   1547         | sed -n 's/.* bytes=\([0-9]*\) .*/\1/p'
   1548 }
   1549 
   1550 function test_congestion() {
   1551     stop_paivana
   1552     # Points at the upstream test_streaming already started.  Its own
   1553     # paivana, so the buffer sizes these cases assert against are
   1554     # stated here rather than inherited.
   1555     local cfg="$SCRATCH/paivana-congestion.conf"
   1556     sed -e "s|@DEST@|http://127.0.0.1:$STREAM_PORT|g" \
   1557         -e "s|@PORT@|$PAIVANA_PORT|g" \
   1558         "$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
   1559     cat >> "$cfg" <<EOF
   1560 REQUEST_BUFFER_MAX = 262144
   1561 RESPONSE_BUFFER_MAX = 262144
   1562 MAX_REQUEST_SIZE = $((512 * 1024 * 1024))
   1563 UPSTREAM_STALL_TIMEOUT = 30 s
   1564 # The concurrency case runs 32 transfers from 127.0.0.1, which is
   1565 # exactly PER_IP_CONNECTION_LIMIT's default: leaving it would have the
   1566 # case measure connection limiting rather than the memory bound, and
   1567 # would do so by refusing whichever request happened to be 33rd.
   1568 PER_IP_CONNECTION_LIMIT = 0
   1569 EOF
   1570     PAIVANA_DEST="http://127.0.0.1:$STREAM_PORT"
   1571     local log="$LOGDIR/paivana.log"
   1572     ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING ) >"$log" 2>&1 &
   1573     PAIVANA_PID=$!
   1574     if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT" "$PAIVANA_PID";
   1575     then
   1576         echo "FAIL: paivana-httpd did not start on port $PAIVANA_PORT" >&2
   1577         tail -n 20 "$log" >&2
   1578         exit 1
   1579     fi
   1580 
   1581     # --- 10: the bound itself ---------------------------------------
   1582     # A body many times the size of the buffers, through a client slow
   1583     # enough that paivana cannot simply hand it straight on.  Peak RSS
   1584     # over baseline is the assertion, and it is the one that makes the
   1585     # rest of the suite mean anything: everything else here would pass
   1586     # against a version that buffered the whole body.
   1587     msg "a $((CONG_BIG / 1024 / 1024)) MiB download through a slow client stays within its buffers"
   1588     local base peak
   1589     base="$(rss_kb "$PAIVANA_PID")"
   1590     "$BUILDDIR/stream_client" \
   1591         "$(PAIVANA_URL "/cl?bytes=$CONG_BIG")" \
   1592         --expect-bytes "$CONG_BIG" \
   1593         --read-rate $((16 * 1024 * 1024)) > "$SCRATCH/sc" 2>&1 &
   1594     local cpid=$!
   1595     watch_rss "$PAIVANA_PID" "$cpid"
   1596     wait "$cpid" || fail "stream_client did not finish"
   1597     sc_is status 200
   1598     sc_is pattern ok
   1599     sc_is bytes "$CONG_BIG"
   1600     # Generous: two 256 KiB rings, MHD's block buffer, libcurl's own
   1601     # buffering and glibc's allocator.  The number that matters is that
   1602     # it does not scale with the body -- buffering would show tens of
   1603     # megabytes here, and CONG_BIG is far above the ceiling that used to
   1604     # apply at all.
   1605     [ "$RSS_SAMPLES" -ge 3 ] || \
   1606         fail "only $RSS_SAMPLES RSS samples taken; the transfer was too fast to have measured anything"
   1607     rss_bound "$base" 16384 "relaying $CONG_BIG bytes"
   1608 
   1609     # --- 11: the origin really was held back ------------------------
   1610     # Same transfer, seen from the other end.  Without backpressure the
   1611     # origin writes its body at loopback speed -- well under a second
   1612     # for this size -- and paivana holds the difference.  With it, the
   1613     # origin can only get as far ahead as the buffers allow, so its own
   1614     # elapsed time tracks the client's.
   1615     msg "the upstream is paced by the client rather than by the socket"
   1616     local oms cms
   1617     oms="$(origin_ms "/cl?bytes=$CONG_BIG")"
   1618     cms="$(sfield total_ms)"
   1619     [ -n "$oms" ] || fail "upstream reported no timing for /cl?bytes=$CONG_BIG"
   1620     [ "$(origin_bytes "/cl?bytes=$CONG_BIG")" = "$CONG_BIG" ] || \
   1621         fail "upstream wrote $(origin_bytes "/cl?bytes=$CONG_BIG") of $CONG_BIG bytes"
   1622     # Half is the slack for the kernel socket buffers on both sides plus
   1623     # the rings; the failure this is looking for is the origin finishing
   1624     # in a fiftieth of the time, not in nine tenths of it.
   1625     [ "$oms" -ge "$((cms / 2))" ] || \
   1626         fail "upstream finished writing in ${oms}ms while the client took ${cms}ms: it was not throttled"
   1627     echo "OK (upstream ${oms}ms, client ${cms}ms)"
   1628 
   1629     # --- 12: the same, in the upload direction ----------------------
   1630     msg "a slow client uploading is likewise paced end to end"
   1631     "$BUILDDIR/stream_client" "$(PAIVANA_URL /sink)" \
   1632         --upload "$CONG_MID" --upload-rate $((16 * 1024 * 1024)) \
   1633         --print-body > "$SCRATCH/sc" 2>&1 \
   1634         || fail "stream_client did not finish"
   1635     sc_is status 200
   1636     case "$(sfield body)" in
   1637         "bytes=$CONG_MID framing=length pattern=ok") ;;
   1638         *) fail "upstream saw: $(sfield body)" ;;
   1639     esac
   1640     oms="$(origin_ms "/sink")"
   1641     cms="$(sfield total_ms)"
   1642     [ -n "$oms" ] || fail "upstream reported no timing for /sink"
   1643     [ "$oms" -ge "$((cms / 2))" ] || \
   1644         fail "upstream finished reading in ${oms}ms while the client took ${cms}ms"
   1645     echo "OK (upstream ${oms}ms, client ${cms}ms)"
   1646 
   1647     # --- 13: many at once -------------------------------------------
   1648     # The per-request cost is what multiplies, so this is where a bound
   1649     # that holds for one request and not for sixteen would show.  Mixed
   1650     # rates so the fast ones finish while the slow ones are still going,
   1651     # which is the state a single-rate run never reaches.
   1652     msg "32 concurrent throttled downloads stay within a bounded total"
   1653     base="$(rss_kb "$PAIVANA_PID")"
   1654     rm -f "$SCRATCH"/cong.*.out
   1655     local i
   1656     local cpids=""
   1657     for i in $(seq 1 32);
   1658     do
   1659         "$BUILDDIR/stream_client" \
   1660             "$(PAIVANA_URL "/cl?bytes=$CONG_SMALL")" \
   1661             --expect-bytes "$CONG_SMALL" \
   1662             --read-rate $(( (i % 4 + 1) * 4 * 1024 * 1024 )) \
   1663             > "$SCRATCH/cong.$i.out" 2>&1 &
   1664         cpids="$cpids $!"
   1665     done
   1666     # shellcheck disable=SC2086
   1667     watch_rss "$PAIVANA_PID" $cpids
   1668     # shellcheck disable=SC2086
   1669     wait $cpids
   1670     local okcount
   1671     okcount="$(cat "$SCRATCH"/cong.*.out | grep -c '^pattern=ok$')"
   1672     [ "$okcount" = "32" ] || \
   1673         fail "only $okcount of 32 concurrent bodies verified"
   1674     grep -hq '^status=200$' "$SCRATCH"/cong.1.out || fail "no 200 seen"
   1675     # 32 requests times two 256 KiB rings is 16 MiB of ceiling; the
   1676     # buffered path would have held 32 times the body instead.
   1677     [ "$RSS_SAMPLES" -ge 3 ] || \
   1678         fail "only $RSS_SAMPLES RSS samples taken across the concurrent transfers"
   1679     rss_bound "$base" 32768 \
   1680         "across 32 concurrent transfers of $((CONG_SMALL / 1024 / 1024)) MiB"
   1681 
   1682     # --- 14: not busy-waiting ---------------------------------------
   1683     # An origin dribbling a byte at a time means paivana spends almost
   1684     # the whole request with nothing to do.  If suspend/resume is wrong
   1685     # -- an MHD content reader returning 0 without suspending, or an
   1686     # unpause that reschedules itself -- the transfer still completes
   1687     # and nothing else in the suite notices; a core is simply pinned for
   1688     # the duration.  CPU time is the only thing that shows it.
   1689     msg "an idle transfer costs no CPU, and its first byte still arrives at once"
   1690     local c0 c1 jiffies
   1691     c0="$(cpu_jiffies "$PAIVANA_PID")"
   1692     "$BUILDDIR/stream_client" \
   1693         "$(PAIVANA_URL "/cl?bytes=1024&rate=200")" \
   1694         --expect-bytes 1024 > "$SCRATCH/sc" 2>&1 \
   1695         || fail "stream_client did not finish"
   1696     c1="$(cpu_jiffies "$PAIVANA_PID")"
   1697     sc_is status 200
   1698     sc_is pattern ok
   1699     jiffies=$((c1 - c0))
   1700     # ~5 s of wall clock, i.e. ~500 jiffies were available to burn.
   1701     [ "$jiffies" -lt 100 ] || \
   1702         fail "paivana used ${jiffies} jiffies of CPU across a ~5 s idle transfer; it is spinning"
   1703     # And the point of streaming at all: the client does not wait for
   1704     # the origin's last byte to see its first.
   1705     [ "$(sfield ttfb_ms)" -lt 1000 ] || \
   1706         fail "first byte took $(sfield ttfb_ms)ms of a ~5 s transfer; the body was buffered"
   1707     echo "OK (${jiffies} jiffies CPU, first byte at $(sfield ttfb_ms)ms of $(sfield total_ms)ms)"
   1708 
   1709     # --- 15: pathological interleaving ------------------------------
   1710     # A 1 KiB receive buffer makes libcurl drain paivana's socket in
   1711     # tiny units, so MHD's content reader is called hundreds of times
   1712     # for a body the default buffer would move in a handful -- and each
   1713     # of those is a chance for the ring to empty and the connection to
   1714     # suspend and resume.  Chunked, because that path re-enters MHD's
   1715     # chunk framing on every one of them.
   1716     msg "a client reading in 1 KiB units survives the pause/resume churn"
   1717     "$BUILDDIR/stream_client" \
   1718         "$(PAIVANA_URL "/chunked?bytes=$CONG_SMALL")" \
   1719         --expect-bytes "$CONG_SMALL" --recv-buffer 1024 \
   1720         > "$SCRATCH/sc" 2>&1 \
   1721         || fail "stream_client did not finish"
   1722     sc_is status 200
   1723     sc_is chunked yes
   1724     sc_is pattern ok
   1725     sc_is bytes "$CONG_SMALL"
   1726     ok
   1727 
   1728     # --- 16: slow at both ends simultaneously -----------------------
   1729     # Neither side able to keep up with the other, on the same request.
   1730     # Both rings spend the transfer alternately full and empty, and the
   1731     # two halves of the state machine have to interleave without either
   1732     # deadlocking or dropping a byte.
   1733     msg "a slow upstream and a slow client on one request"
   1734     "$BUILDDIR/stream_client" \
   1735         "$(PAIVANA_URL "/cl?bytes=$CONG_SMALL&rate=$((8 * 1024 * 1024))")" \
   1736         --expect-bytes "$CONG_SMALL" --read-rate $((4 * 1024 * 1024)) \
   1737         > "$SCRATCH/sc" 2>&1 \
   1738         || fail "stream_client did not finish"
   1739     sc_is status 200
   1740     sc_is pattern ok
   1741     sc_is bytes "$CONG_SMALL"
   1742     ok
   1743 
   1744     stop_paivana
   1745 }
   1746 
   1747 
   1748 function test_short_body() {
   1749     # An incomplete message must be treated as a failure (RFC 9112
   1750     # section 8.1.2), and with a streamed response the only way left to
   1751     # say so is to break the framing.  The upstream's status and its
   1752     # Content-Length reach the client long before the body runs out --
   1753     # they cannot be retracted afterwards -- so the client sees a 200
   1754     # promising 100000 bytes and a connection that closes after 1000.
   1755     # curl reports that as error 18, which is the assertion here.
   1756     #
   1757     # A 502 used to be possible because the whole body was assembled
   1758     # before anything was sent.  What must never happen either way is a
   1759     # short body served as if it were complete: that is what the
   1760     # "bytes remaining" check below rules out.
   1761     msg "upstream body shorter than its Content-Length truncates the client"
   1762     if ! command -v python3 >/dev/null 2>&1;
   1763     then
   1764         echo "SKIP (python3 missing)"
   1765         return
   1766     fi
   1767     stop_paivana
   1768     start_truncating_upstream "$TRUNC_PORT"
   1769     start_paivana "http://127.0.0.1:$TRUNC_PORT"
   1770     local out status
   1771     # curl exits 18 (CURLE_PARTIAL_FILE) here, so the pipeline must not
   1772     # be allowed to fail the test; the exit code is part of what is
   1773     # being asserted.
   1774     out="$(curl -sS -o "$SCRATCH/body" -D "$SCRATCH/hdr" \
   1775                 -w '%{http_code}' --max-time 30 \
   1776                 "$(PAIVANA_URL /short)" 2>"$SCRATCH/err")"
   1777     status=$?
   1778     [ "$status" = "18" ] || \
   1779         fail "curl exit=$status want=18 (partial file); a 0 here means the truncation was hidden from the client"
   1780     [ "$out" = "200" ] || \
   1781         fail "status=$out want=200 (the upstream's status is already sent when the body runs out)"
   1782     grep -qi '^Content-Length: 100000' "$SCRATCH/hdr" || \
   1783         fail "client was not told the upstream's declared length: $(grep -i content-length "$SCRATCH/hdr")"
   1784     [ "$(wc -c < "$SCRATCH/body")" = "1000" ] || \
   1785         fail "got $(wc -c < "$SCRATCH/body") bytes, want the 1000 the upstream actually sent"
   1786     ok
   1787     stop_paivana
   1788 }
   1789 
   1790 
   1791 function test_upstream_down() {
   1792     msg "upstream down yields 502 Bad Gateway"
   1793     # Re-point paivana at a port with nothing listening.
   1794     stop_paivana
   1795     start_paivana "http://127.0.0.1:$DEAD_PORT"
   1796     local status
   1797     status="$(curl -sS -o "$SCRATCH/body" -w '%{http_code}' \
   1798                    --max-time 10 \
   1799                    "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
   1800         || fail "curl: $(cat "$SCRATCH/err")"
   1801     [ "$status" = "502" ] || fail "status=$status want=502"
   1802     grep -qi 'bad gateway' "$SCRATCH/body" || \
   1803         fail "no 'Bad Gateway' in body"
   1804     ok
   1805 }
   1806 
   1807 # curl with multiple URLs on one command line uses HTTP keep-alive
   1808 # (not true pipelining, but exercises the same code path in paivana
   1809 # of handling successive requests on one TCP connection).
   1810 function test_keepalive_curl() {
   1811     msg "curl keep-alive: 3 sequential GETs on one connection"
   1812     local out
   1813     out="$(curl -sS --http1.1 \
   1814                 -w '\n@status=%{http_code}\n' \
   1815                 "$(PAIVANA_URL /hello)" \
   1816                 "$(PAIVANA_URL /hello)" \
   1817                 "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
   1818         || fail "curl: $(cat "$SCRATCH/err")"
   1819     local count
   1820     count="$(printf '%s\n' "$out" | grep -c '^Hello from')"
   1821     [ "$count" = "3" ] || fail "got $count Hello lines; want 3; out:"$'\n'"$out"
   1822     ok
   1823 }
   1824 
   1825 function test_wget_basic() {
   1826     msg "wget fetch (third-party client interop)"
   1827     if ! command -v wget >/dev/null 2>&1; then
   1828         echo "SKIP (wget missing)"
   1829         return
   1830     fi
   1831     local body
   1832     body="$(wget -qO- --timeout=5 "$(PAIVANA_URL /hello)")" \
   1833         || fail "wget failed"
   1834     echo "$body" | grep -q '^Hello from' \
   1835         || fail "unexpected body from wget: $body"
   1836     ok
   1837 }
   1838 
   1839 function test_pipelined() {
   1840     msg "HTTP/1.1 pipelined requests (4 back-to-back on one TCP socket)"
   1841     if [ ! -x "$PIPELINE_CLIENT" ]; then
   1842         echo "SKIP (pipeline_client not built)"
   1843         return
   1844     fi
   1845     local out
   1846     out="$("$PIPELINE_CLIENT" 127.0.0.1 "$PAIVANA_PORT" \
   1847               /hello /status/201 /hello /status/404 2>"$SCRATCH/err")" \
   1848         || fail "pipeline_client: $(cat "$SCRATCH/err")"
   1849     printf '%s\n' "$out" >"$SCRATCH/pipeline.out"
   1850     local n
   1851     n="$(grep -c '^--- response' "$SCRATCH/pipeline.out")"
   1852     [ "$n" = "4" ] || fail "got $n responses, want 4; output:"$'\n'"$out"
   1853     # Order preserved: responses must match the request sequence.
   1854     grep -q '^--- response 0: status=200' "$SCRATCH/pipeline.out" \
   1855         || fail "response 0: wrong status; out:"$'\n'"$out"
   1856     grep -q '^--- response 1: status=201' "$SCRATCH/pipeline.out" \
   1857         || fail "response 1: wrong status; out:"$'\n'"$out"
   1858     grep -q '^--- response 2: status=200' "$SCRATCH/pipeline.out" \
   1859         || fail "response 2: wrong status; out:"$'\n'"$out"
   1860     grep -q '^--- response 3: status=404' "$SCRATCH/pipeline.out" \
   1861         || fail "response 3: wrong status; out:"$'\n'"$out"
   1862     ok
   1863 }
   1864 
   1865 ######################################################################
   1866 # Forwarding headers (X-Forwarded-*), with and without -f.
   1867 #
   1868 # Which of two roles paivana plays is decided by -f, the same flag
   1869 # that decides where the access cookie's client address comes from:
   1870 # without it we are the outermost proxy and a client's assertions are
   1871 # replaced with what we can see; with it we are behind a trusted proxy
   1872 # and extend the chain it gave us.  Both directions are asserted here,
   1873 # because getting either wrong is silent -- the request still
   1874 # succeeds, it just carries the wrong client.
   1875 ######################################################################
   1876 
   1877 # Echo the upstream's view of one header.  $1 = header name.
   1878 function upstream_header() {
   1879     grep -i "^$1:" "$SCRATCH/body" | tr -d '\r' | sed -e "s/^[^:]*: *//"
   1880 }
   1881 
   1882 function test_forwarded_no_flag() {
   1883     msg "no -f: client X-Forwarded-* are replaced, not believed"
   1884     curl -sS -H 'X-Forwarded-For: 1.2.3.4' \
   1885          -H 'X-Forwarded-Proto: https' \
   1886          -H 'X-Forwarded-Host: evil.example.com' \
   1887          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   1888         || fail "curl: $(cat "$SCRATCH/err")"
   1889     local xff proto host
   1890     xff="$(upstream_header x-forwarded-for)"
   1891     proto="$(upstream_header x-forwarded-proto)"
   1892     host="$(upstream_header x-forwarded-host)"
   1893     [ "$xff" = "127.0.0.1" ] || \
   1894         fail "X-Forwarded-For='$xff', want '127.0.0.1' (client's 1.2.3.4 must not survive)"
   1895     # The client asserted https.  We are plain HTTP, and without -f
   1896     # nothing the client says about the scheme may be believed --
   1897     # otherwise it picks the scheme of the URLs we generate for it.
   1898     [ "$proto" = "http" ] || \
   1899         fail "X-Forwarded-Proto='$proto', want 'http' (client asserted https)"
   1900     case "$host" in
   1901         *evil.example.com*) fail "client's X-Forwarded-Host reached upstream: '$host'";;
   1902     esac
   1903     ok
   1904 }
   1905 
   1906 function test_forwarded_with_flag() {
   1907     msg "-f: inbound chain is extended, not discarded"
   1908     stop_paivana
   1909     start_paivana "$PAIVANA_DEST" -f
   1910     curl -sS -H 'X-Forwarded-For: 203.0.113.7, 198.51.100.9' \
   1911          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   1912         || fail "curl: $(cat "$SCRATCH/err")"
   1913     local xff
   1914     xff="$(upstream_header x-forwarded-for)"
   1915     # Our own peer is appended to the right of the chain we were given.
   1916     [ "$xff" = "203.0.113.7, 198.51.100.9, 127.0.0.1" ] || \
   1917         fail "X-Forwarded-For='$xff', want '203.0.113.7, 198.51.100.9, 127.0.0.1'"
   1918     ok
   1919 
   1920     msg "-f: trusted X-Forwarded-Proto / -Host are passed through"
   1921     curl -sS -H 'X-Forwarded-Proto: https' \
   1922          -H 'X-Forwarded-Host: public.example.com' \
   1923          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   1924         || fail "curl: $(cat "$SCRATCH/err")"
   1925     local proto host
   1926     proto="$(upstream_header x-forwarded-proto)"
   1927     host="$(upstream_header x-forwarded-host)"
   1928     [ "$proto" = "https" ] || \
   1929         fail "X-Forwarded-Proto='$proto', want 'https' (trusted proxy said so)"
   1930     [ "$host" = "public.example.com" ] || \
   1931         fail "X-Forwarded-Host='$host', want 'public.example.com'"
   1932     ok
   1933 
   1934     msg "-f: no inbound chain still yields our own peer"
   1935     curl -sS -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   1936         || fail "curl: $(cat "$SCRATCH/err")"
   1937     xff="$(upstream_header x-forwarded-for)"
   1938     [ "$xff" = "127.0.0.1" ] || \
   1939         fail "X-Forwarded-For='$xff', want '127.0.0.1'"
   1940     ok
   1941 
   1942     msg "-f: a repeated X-Forwarded-For is combined into one chain"
   1943     # RFC 9110 §5.3: two field lines of a list header mean the same as
   1944     # one comma-joined line, and must reach the origin as one header.
   1945     curl -sS -H 'X-Forwarded-For: 203.0.113.7' \
   1946          -H 'X-Forwarded-For: 198.51.100.9' \
   1947          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   1948         || fail "curl: $(cat "$SCRATCH/err")"
   1949     local n
   1950     n="$(grep -ci '^x-forwarded-for:' "$SCRATCH/body")"
   1951     [ "$n" = "1" ] || fail "upstream saw $n X-Forwarded-For headers, want 1"
   1952     xff="$(upstream_header x-forwarded-for)"
   1953     [ "$xff" = "203.0.113.7, 198.51.100.9, 127.0.0.1" ] || \
   1954         fail "X-Forwarded-For='$xff', want '203.0.113.7, 198.51.100.9, 127.0.0.1'"
   1955     ok
   1956 
   1957     stop_paivana
   1958     start_paivana "$PAIVANA_DEST"
   1959 }
   1960 
   1961 function test_forwarded_unix() {
   1962     # The deployment the Debian packaging actually ships: paivana on a
   1963     # Unix socket behind nginx/Apache.  A Unix peer has no address, so
   1964     # without -f there is nothing to put in X-Forwarded-For at all, and
   1965     # with -f the inbound chain is the only client information that
   1966     # exists -- losing it leaves the origin blind.
   1967     msg "unix socket, -f: inbound chain survives the address-less hop"
   1968     local dest="$PAIVANA_DEST"
   1969     stop_paivana
   1970     start_paivana_unix "$dest" -f
   1971     curl -sS --unix-socket "$PAIVANA_SOCK" \
   1972          -H 'X-Forwarded-For: 203.0.113.7' \
   1973          -o "$SCRATCH/body" http://localhost/echo-headers 2>"$SCRATCH/err" \
   1974         || fail "curl: $(cat "$SCRATCH/err")"
   1975     local xff
   1976     xff="$(upstream_header x-forwarded-for)"
   1977     # Nothing is appended: a Unix peer has no address, and inventing
   1978     # one ("127.0.0.1") would be indistinguishable from a real
   1979     # loopback client.  The hop is recorded in Via instead.
   1980     [ "$xff" = "203.0.113.7" ] || \
   1981         fail "X-Forwarded-For='$xff', want '203.0.113.7' (unadorned)"
   1982     grep -qi '^via:.*paivana' "$SCRATCH/body" || \
   1983         fail "Via does not record the paivana hop; headers:"$'\n'"$(cat "$SCRATCH/body")"
   1984     ok
   1985 
   1986     msg "unix socket, no -f: no X-Forwarded-For is invented"
   1987     stop_paivana
   1988     start_paivana_unix "$dest"
   1989     curl -sS --unix-socket "$PAIVANA_SOCK" \
   1990          -H 'X-Forwarded-For: 1.2.3.4' \
   1991          -o "$SCRATCH/body" http://localhost/echo-headers 2>"$SCRATCH/err" \
   1992         || fail "curl: $(cat "$SCRATCH/err")"
   1993     grep -qi '^x-forwarded-for:' "$SCRATCH/body" && \
   1994         fail "upstream saw an X-Forwarded-For we cannot substantiate:"$'\n'"$(cat "$SCRATCH/body")"
   1995     grep -qi '^via:.*paivana' "$SCRATCH/body" || \
   1996         fail "Via does not record the paivana hop"
   1997     ok
   1998 
   1999     stop_paivana
   2000     start_paivana "$dest"
   2001 }
   2002 
   2003 function test_forwarded_rfc7239() {
   2004     stop_paivana
   2005     start_paivana "$PAIVANA_DEST" -f
   2006 
   2007     msg "-f: RFC 7239 Forwarded is extended with our own element"
   2008     curl -sS -H 'Forwarded: for=203.0.113.7;proto=https;host=public.example.com' \
   2009          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   2010         || fail "curl: $(cat "$SCRATCH/err")"
   2011     local fwd
   2012     fwd="$(upstream_header forwarded)"
   2013     case "$fwd" in
   2014         "for=203.0.113.7;proto=https;host=public.example.com, for=127.0.0.1;by=_paivana;"*) ;;
   2015         *) fail "Forwarded not extended correctly: '$fwd'";;
   2016     esac
   2017     ok
   2018 
   2019     # A proxy that speaks only RFC 7239 must still be understood by an
   2020     # origin that speaks only X-Forwarded-*, or we would report the
   2021     # proxy as the client and the wrong scheme with it.
   2022     msg "-f: Forwarded is mirrored into the X-Forwarded-* headers"
   2023     local xff proto host
   2024     xff="$(upstream_header x-forwarded-for)"
   2025     proto="$(upstream_header x-forwarded-proto)"
   2026     host="$(upstream_header x-forwarded-host)"
   2027     [ "$xff" = "203.0.113.7, 127.0.0.1" ] || \
   2028         fail "X-Forwarded-For='$xff', want '203.0.113.7, 127.0.0.1'"
   2029     [ "$proto" = "https" ] || \
   2030         fail "X-Forwarded-Proto='$proto', want 'https' (Forwarded said so)"
   2031     [ "$host" = "public.example.com" ] || \
   2032         fail "X-Forwarded-Host='$host', want 'public.example.com'"
   2033     ok
   2034 
   2035     # RFC 7239 §6.3: "unknown" is a legal node identifier, but
   2036     # X-Forwarded-For has no way to say it -- so no chain is
   2037     # synthesized rather than one with a hop silently missing.
   2038     msg "-f: a Forwarded chain that X-Forwarded-For cannot express is not faked"
   2039     curl -sS -H 'Forwarded: for=unknown' \
   2040          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   2041         || fail "curl: $(cat "$SCRATCH/err")"
   2042     xff="$(upstream_header x-forwarded-for)"
   2043     [ "$xff" = "127.0.0.1" ] || \
   2044         fail "X-Forwarded-For='$xff', want just our own peer '127.0.0.1'"
   2045     fwd="$(upstream_header forwarded)"
   2046     case "$fwd" in
   2047         "for=unknown, for=127.0.0.1;by=_paivana;"*) ;;
   2048         *) fail "Forwarded should still carry the unknown hop: '$fwd'";;
   2049     esac
   2050     ok
   2051 
   2052     msg "no -f: a client's Forwarded is replaced, not extended"
   2053     stop_paivana
   2054     start_paivana "$PAIVANA_DEST"
   2055     curl -sS -H 'Forwarded: for=1.2.3.4;proto=https' \
   2056          -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
   2057         || fail "curl: $(cat "$SCRATCH/err")"
   2058     fwd="$(upstream_header forwarded)"
   2059     case "$fwd" in
   2060         *1.2.3.4*) fail "client's Forwarded element survived: '$fwd'";;
   2061     esac
   2062     case "$fwd" in
   2063         "for=127.0.0.1;by=_paivana;proto=http;"*) ;;
   2064         *) fail "unexpected Forwarded: '$fwd'";;
   2065     esac
   2066     ok
   2067 }
   2068 
   2069 function test_forwarded_unix_rfc7239() {
   2070     # Unlike X-Forwarded-For, RFC 7239 has a spelling for a hop with no
   2071     # address (§6.3 "unknown"), so the Unix hop need not go unrecorded.
   2072     msg "unix socket: our Forwarded element says for=unknown"
   2073     local dest="$PAIVANA_DEST"
   2074     stop_paivana
   2075     start_paivana_unix "$dest" -f
   2076     curl -sS --unix-socket "$PAIVANA_SOCK" \
   2077          -H 'Forwarded: for=203.0.113.7' \
   2078          -o "$SCRATCH/body" http://localhost/echo-headers 2>"$SCRATCH/err" \
   2079         || fail "curl: $(cat "$SCRATCH/err")"
   2080     local fwd
   2081     fwd="$(upstream_header forwarded)"
   2082     case "$fwd" in
   2083         "for=203.0.113.7, for=unknown;by=_paivana;"*) ;;
   2084         *) fail "unexpected Forwarded over a Unix socket: '$fwd'";;
   2085     esac
   2086     ok
   2087     stop_paivana
   2088     start_paivana "$dest"
   2089 }
   2090 
   2091 ######################################################################
   2092 # TRUSTED_PROXIES configuration validation.
   2093 #
   2094 # The GNUnet policy parsers accept several things that mean "nothing
   2095 # usable" without saying so -- a missing terminator, a /0 network
   2096 # (which is indistinguishable from the list terminator), an address of
   2097 # the wrong family.  Quietly trusting nobody would send every visitor
   2098 # to the socket address with no hint why, so the loader refuses to
   2099 # start instead.  These cases pin that.
   2100 #
   2101 # Worse than "nothing usable" is "some of it": a list whose LAST entry
   2102 # has no ';' loses that entry and reports success for the rest, so a
   2103 # single typo would leave every client behind the unlisted proxy
   2104 # sharing that proxy's address -- and one paid cookie.  The loader
   2105 # counts the entries it got back against the ';' that went in.
   2106 ######################################################################
   2107 
   2108 # Start paivana with an extra config line and report whether it came
   2109 # up.  Echoes "started" or "refused".
   2110 #
   2111 # "Refused" is read off wait_for_port giving up, so the pid has to be
   2112 # passed: a refused config makes paivana exit in milliseconds, and
   2113 # without the liveness check the verdict would come from a five-second
   2114 # timeout instead -- and would be wrong outright if anything else were
   2115 # holding the port, since the loop would then see a listener and call
   2116 # every refusal an acceptance.
   2117 function paivana_with_config_line() {
   2118     local line="$1"
   2119     local nofile="${2:-}"
   2120     local cfg="$SCRATCH/startup.conf"
   2121     sed -e "s|@DEST@|http://127.0.0.1:$MHD_PORT|g" \
   2122         -e "s|@PORT@|$PAIVANA_PORT|g" \
   2123         "$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
   2124     printf '%s\n' "$line" >> "$cfg"
   2125     local log="$LOGDIR/startup.log"
   2126     if [ -n "$nofile" ];
   2127     then
   2128         ( ulimit -n "$nofile"; exec "$PAIVANA_HTTPD" -c "$cfg" -n -f -L ERROR ) >"$log" 2>&1 &
   2129     else
   2130         ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -f -L ERROR ) >"$log" 2>&1 &
   2131     fi
   2132     local pid=$!
   2133     if wait_for_port 127.0.0.1 "$PAIVANA_PORT" "$pid";
   2134     then
   2135         kill -TERM "$pid" 2>/dev/null
   2136         wait "$pid" 2>/dev/null
   2137         echo "started"
   2138         return
   2139     fi
   2140     kill -TERM "$pid" 2>/dev/null
   2141     wait "$pid" 2>/dev/null
   2142     echo "refused"
   2143 }
   2144 
   2145 function test_resource_budget_config() {
   2146     stop_paivana
   2147     local r
   2148 
   2149     # The default ring calculation is exactly
   2150     # 352 * (262144 + 262144) = 184549376 bytes.  Testing one byte on
   2151     # either side pins the overflow-safe aggregate comparison.
   2152     for bad in \
   2153         'CONNECTION_LIMIT = 385' \
   2154         'PAYMENT_CONNECTION_LIMIT = 0' \
   2155         'CONNECTION_LIMIT = 32' \
   2156         'RELAY_MEMORY_LIMIT = 184549375'
   2157     do
   2158         msg "startup refused: $bad"
   2159         r="$(paivana_with_config_line "$bad")"
   2160         [ "$r" = "refused" ] || \
   2161             fail "paivana started with an unsafe resource budget ($bad)"
   2162         ok
   2163     done
   2164 
   2165     for good in \
   2166         'CONNECTION_LIMIT = 384' \
   2167         'PAYMENT_CONNECTION_LIMIT = 32' \
   2168         'RELAY_MEMORY_LIMIT = 184549376' \
   2169         'SHUTDOWN_GRACE_PERIOD = 0 s'
   2170     do
   2171         msg "startup accepted: $good"
   2172         r="$(paivana_with_config_line "$good")"
   2173         [ "$r" = "started" ] || \
   2174             fail "paivana refused a safe resource budget ($good); log:"$'\n'"$(cat "$LOGDIR/startup.log")"
   2175         ok
   2176     done
   2177 
   2178     # CONNECTION_LIMIT=10 charges 20 descriptors plus the documented
   2179     # 256-descriptor reserve.  The soft limit is a hard startup boundary,
   2180     # independently of the much larger select() ceiling.
   2181     msg "startup refused one descriptor below its calculated requirement"
   2182     r="$(paivana_with_config_line \
   2183         $'CONNECTION_LIMIT = 10\nPAYMENT_CONNECTION_LIMIT = 2' 275)"
   2184     [ "$r" = "refused" ] || fail "paivana ignored RLIMIT_NOFILE=275"
   2185     ok
   2186 
   2187     msg "startup accepted at its exact calculated descriptor requirement"
   2188     r="$(paivana_with_config_line \
   2189         $'CONNECTION_LIMIT = 10\nPAYMENT_CONNECTION_LIMIT = 2' 276)"
   2190     [ "$r" = "started" ] || \
   2191         fail "paivana refused RLIMIT_NOFILE=276; log:"$'\n'"$(cat "$LOGDIR/startup.log")"
   2192     ok
   2193 
   2194     start_paivana "http://127.0.0.1:$MHD_PORT"
   2195 }
   2196 
   2197 function test_controlled_overload() {
   2198     stop_paivana
   2199     start_paivana_with_config "http://127.0.0.1:$MHD_PORT" \
   2200         $'CONNECTION_LIMIT = 4\nPAYMENT_CONNECTION_LIMIT = 1'
   2201 
   2202     # Three ordinary requests are the entire non-payment budget.  They remain
   2203     # suspended in Paivana while the single-threaded test origin answers its
   2204     # slow requests, leaving the fourth MHD connection for an immediate
   2205     # overload response and then for the reserved payment request.
   2206     local pids=()
   2207     for i in 1 2 3;
   2208     do
   2209         curl -sS -o /dev/null -w '%{http_code}' \
   2210             "$(PAIVANA_URL /slow/1000)" >"$SCRATCH/ordinary-$i.status" &
   2211         pids+=("$!")
   2212     done
   2213     sleep 0.3
   2214 
   2215     msg "ordinary capacity returns a controlled 503"
   2216     local status
   2217     status="$(curl -sS --max-time 2 -D "$SCRATCH/overload.headers" \
   2218         -o "$SCRATCH/overload.body" -w '%{http_code}' \
   2219         "$(PAIVANA_URL /small)")" || fail "ordinary overload request failed"
   2220     [ "$status" = 503 ] || fail "ordinary overload status=$status, want 503"
   2221     grep -qi '^Retry-After: 1' "$SCRATCH/overload.headers" || \
   2222         fail "ordinary overload response lacks Retry-After: 1"
   2223     grep -qi '^Connection: close' "$SCRATCH/overload.headers" || \
   2224         fail "ordinary overload response lacks Connection: close"
   2225     ok
   2226 
   2227     msg "the reserved slot still admits the payment endpoint"
   2228     status="$(curl -sS --max-time 2 -o "$SCRATCH/payment-disabled.body" \
   2229         -w '%{http_code}' -H 'Content-Type: application/json' -X POST \
   2230         -d '{}' "$(PAIVANA_URL /.well-known/paivana)")" || \
   2231         fail "reserved payment request failed"
   2232     [ "$status" = 501 ] || \
   2233         fail "reserved payment status=$status, want the -n response 501"
   2234     ok
   2235 
   2236     for i in 0 1 2;
   2237     do
   2238         wait "${pids[$i]}" || fail "ordinary request $((i + 1)) failed"
   2239         [ "$(cat "$SCRATCH/ordinary-$((i + 1)).status")" = 200 ] || \
   2240             fail "ordinary request $((i + 1)) did not complete"
   2241     done
   2242     stop_paivana
   2243     start_paivana "http://127.0.0.1:$MHD_PORT"
   2244 }
   2245 
   2246 function test_graceful_shutdown() {
   2247     stop_paivana
   2248     start_paivana_with_config "http://127.0.0.1:$MHD_PORT" \
   2249         $'CONNECTION_LIMIT = 10\nPAYMENT_CONNECTION_LIMIT = 2\nSHUTDOWN_GRACE_PERIOD = 2 s' INFO
   2250 
   2251     curl -sS -o /dev/null -w '%{http_code}' \
   2252         "$(PAIVANA_URL /slow/1000)" >"$SCRATCH/drain.status" &
   2253     local curl_pid=$!
   2254     sleep 0.2
   2255     msg "SIGTERM drains an accepted request within the grace period"
   2256     kill -TERM "$PAIVANA_PID"
   2257     wait "$curl_pid" || fail "request was dropped during graceful shutdown"
   2258     [ "$(cat "$SCRATCH/drain.status")" = 200 ] || \
   2259         fail "drained request did not retain its 200 response"
   2260     wait "$PAIVANA_PID" || fail "paivana exited unsuccessfully after draining"
   2261     PAIVANA_PID=""
   2262     grep -q 'Graceful shutdown drained all requests' \
   2263         "$LOGDIR/paivana-custom.log" || \
   2264         fail "graceful-drain completion was not logged"
   2265     ok
   2266 
   2267     start_paivana_with_config "http://127.0.0.1:$MHD_PORT" \
   2268         $'CONNECTION_LIMIT = 10\nPAYMENT_CONNECTION_LIMIT = 2\nSHUTDOWN_GRACE_PERIOD = 100 ms' INFO
   2269 
   2270     curl -sS -o /dev/null "$(PAIVANA_URL /slow/2000)" &
   2271     curl_pid=$!
   2272     sleep 0.2
   2273     msg "the shutdown deadline cancels a suspended request without aborting"
   2274     kill -TERM "$PAIVANA_PID"
   2275     # The forced shutdown deliberately terminates this client request.  Its
   2276     # result is immaterial; the daemon must exit normally rather than letting
   2277     # MHD abort because the connection was still suspended.
   2278     wait "$curl_pid" || :
   2279     local paivana_status
   2280     if wait "$PAIVANA_PID";
   2281     then
   2282         paivana_status=0
   2283     else
   2284         paivana_status=$?
   2285     fi
   2286     PAIVANA_PID=""
   2287     [ "$paivana_status" = 0 ] || \
   2288         fail "paivana exited with status $paivana_status at the drain deadline"
   2289     grep -q 'Graceful shutdown deadline reached with 1 active request' \
   2290         "$LOGDIR/paivana-custom.log" || \
   2291         fail "graceful-drain deadline was not reached with an active request"
   2292     ok
   2293 
   2294     start_paivana "http://127.0.0.1:$MHD_PORT"
   2295 }
   2296 
   2297 function test_trusted_proxies_config() {
   2298     stop_paivana
   2299     local r
   2300 
   2301     # Bad values must be refused loudly rather than silently ignored.
   2302     for bad in \
   2303         'TRUSTED_PROXIES = 10.0.0.0/8' \
   2304         'TRUSTED_PROXIES = 0.0.0.0/0;' \
   2305         'TRUSTED_PROXIES = ::1;' \
   2306         'TRUSTED_PROXIES = garbage;' \
   2307         'TRUSTED_PROXIES = 10.0.0.0/8;192.168.0.0/16' \
   2308         'TRUSTED_PROXIES = 10.0.0.0/8;garbage' \
   2309         'TRUSTED_PROXIES6 = 2001:db8::/32' \
   2310         'TRUSTED_PROXIES6 = 2001:db8::/32;fe80::/10' \
   2311         'TRUSTED_PROXIES6 = ::/0;' \
   2312         'TRUSTED_PROXIES6 = 2001:db8::/32; fe80::/10;'
   2313     do
   2314         msg "startup refused: $bad"
   2315         r="$(paivana_with_config_line "$bad")"
   2316         [ "$r" = "refused" ] || \
   2317             fail "paivana started with an unusable policy ($bad)"
   2318         ok
   2319     done
   2320 
   2321     # ...and good ones must of course still start.
   2322     for good in \
   2323         'TRUSTED_PROXIES = 10.0.0.0/8;192.168.0.0/16;' \
   2324         'TRUSTED_PROXIES = 127.0.0.1;' \
   2325         'TRUSTED_PROXIES6 = 2001:db8::/32;fe80::/10;' \
   2326         'TRUSTED_PROXIES6 = ::1;'
   2327     do
   2328         msg "startup accepted: $good"
   2329         r="$(paivana_with_config_line "$good")"
   2330         [ "$r" = "started" ] || \
   2331             fail "paivana refused a usable policy ($good); log:"$'\n'"$(cat "$LOGDIR/startup.log")"
   2332         ok
   2333     done
   2334 
   2335     start_paivana "http://127.0.0.1:$MHD_PORT"
   2336 }
   2337 
   2338 ######################################################################
   2339 # WHITELIST configuration validation.
   2340 #
   2341 # WHITELIST names the paths served without payment, and paivana wraps
   2342 # it in "^(%s)$" before regcomp: regexec(3) is unanchored, so a
   2343 # WHITELIST of "/free/" would otherwise waive payment for every URL
   2344 # merely *containing* it, and the group keeps an alternation from
   2345 # binding the anchors to only its outer branches.
   2346 #
   2347 # What the suite can reach of this is the loading, not the matching:
   2348 # regcomp happens at config time regardless of -n, while the regexec
   2349 # sits behind the paywall that -n switches off, and paivana will not
   2350 # start without -n unless a merchant backend is there to serve it
   2351 # templates.  So these cases pin that an unusable expression is
   2352 # refused rather than carried into the process -- the alternative
   2353 # being a paivana that runs with an uninitialised regex_t.
   2354 #
   2355 # The last two bad values are the anchoring itself, as far as it can
   2356 # be reached from here.  "a)|(b" and "(a$|^b" do not balance on their
   2357 # own, so wrapping them yields "^(a)|(b)$" and "^((a$|^b))$" -- an
   2358 # alternation that has climbed out of the group, with one branch
   2359 # anchored on one side only and the whitelist consequently matching
   2360 # far more than it says.  paivana compiles the value bare first for
   2361 # exactly this reason, so the configuration is refused rather than
   2362 # accepted into a regex that means something else.
   2363 ######################################################################
   2364 
   2365 function test_whitelist_config() {
   2366     stop_paivana
   2367     local r
   2368 
   2369     for bad in \
   2370         'WHITELIST = *invalid(' \
   2371         'WHITELIST = /free/[' \
   2372         'WHITELIST = /free/\' \
   2373         'WHITELIST = a)|(b' \
   2374         'WHITELIST = (a$|^b'
   2375     do
   2376         msg "startup refused: $bad"
   2377         r="$(paivana_with_config_line "$bad")"
   2378         [ "$r" = "refused" ] || \
   2379             fail "paivana started with an uncompilable WHITELIST ($bad)"
   2380         ok
   2381     done
   2382 
   2383     for good in \
   2384         'WHITELIST = /free/.*' \
   2385         'WHITELIST = /free/.*|/assets/.*' \
   2386         'WHITELIST = ^/free/.*$'
   2387     do
   2388         msg "startup accepted: $good"
   2389         r="$(paivana_with_config_line "$good")"
   2390         [ "$r" = "started" ] || \
   2391             fail "paivana refused a usable WHITELIST ($good); log:"$'\n'"$(cat "$LOGDIR/startup.log")"
   2392         ok
   2393     done
   2394 
   2395     start_paivana "http://127.0.0.1:$MHD_PORT"
   2396 }
   2397 
   2398 ######################################################################
   2399 # The payment endpoint under -n.
   2400 #
   2401 # POST /.well-known/paivana is the one paywall-side branch that -n
   2402 # does not shield: the handler answers 501 rather than falling through
   2403 # to the proxy.  Everything else about the endpoint is unreachable
   2404 # here, but this much is worth pinning, because the two ways to get it
   2405 # wrong are both silent.  Forwarding the POST upstream would hand the
   2406 # origin a request carrying payment data it has no business seeing;
   2407 # claiming the path for every method would shadow whatever the origin
   2408 # serves at that URL.
   2409 ######################################################################
   2410 
   2411 function test_paywall_disabled_endpoint() {
   2412     msg "-n: POST /.well-known/paivana is 501, not forwarded"
   2413     local status
   2414     status="$(curl -sS -D "$SCRATCH/hdrs" -o "$SCRATCH/body" -w '%{http_code}' \
   2415                    -X POST -H 'Content-Type: application/json' \
   2416                    --data '{}' \
   2417                    "$(PAIVANA_URL /.well-known/paivana)" \
   2418                    2>"$SCRATCH/err")" \
   2419         || fail "curl: $(cat "$SCRATCH/err")"
   2420     [ "$status" = "501" ] || fail "status=$status want=501"
   2421     # An upstream that had seen the request would have labelled the
   2422     # response; paivana answering for itself does not.
   2423     if grep -qi '^x-upstream:' "$SCRATCH/hdrs";
   2424     then
   2425         fail "the POST reached the upstream"
   2426     fi
   2427     ok
   2428 
   2429     # `is_paivana' is set only for POST, so the endpoint must not
   2430     # swallow the origin's own URL space at that path.
   2431     msg "-n: GET /.well-known/paivana is forwarded like any other path"
   2432     status="$(curl -sS -D "$SCRATCH/hdrs" -o "$SCRATCH/body" -w '%{http_code}' \
   2433                    "$(PAIVANA_URL /.well-known/paivana)" 2>"$SCRATCH/err")" \
   2434         || fail "curl: $(cat "$SCRATCH/err")"
   2435     [ "$status" != "501" ] || fail "GET was answered by the paywall handler"
   2436     grep -qi '^x-upstream:' "$SCRATCH/hdrs" || \
   2437         fail "GET did not reach the upstream (status=$status)"
   2438     ok
   2439 }
   2440 
   2441 ######################################################################
   2442 # Drive the tests.
   2443 ######################################################################
   2444 
   2445 echo "=== paivana reverse-proxy tests ==="
   2446 echo "Temp dir: $SCRATCH"
   2447 echo "Paivana binary: $PAIVANA_HTTPD"
   2448 echo "Source dir: $SRCDIR"
   2449 echo "Build dir: $BUILDDIR"
   2450 echo "Ports: $PORT_BASE + 1..8, 99, 100"
   2451 
   2452 require_ports_free "$MHD_PORT" "$GO_PORT" "$PY_PORT" "$RS_PORT" \
   2453                    "$EARLY_PORT" "$NODRAIN_PORT" "$TRUNC_PORT" \
   2454                    "$STREAM_PORT" "$DEAD_PORT" "$PAIVANA_PORT"
   2455 
   2456 start_upstreams
   2457 
   2458 # --- C / libmicrohttpd upstream ---------------------------------------
   2459 start_paivana "http://127.0.0.1:$MHD_PORT"
   2460 run_battery "mhd"
   2461 
   2462 test_method_not_allowed
   2463 test_upload_too_big
   2464 test_upload_too_big_early
   2465 test_upload_too_big_no_continue
   2466 test_upload_too_big_chunked
   2467 test_keepalive_curl
   2468 test_wget_basic
   2469 test_pipelined
   2470 test_forwarded_no_flag
   2471 test_forwarded_with_flag
   2472 test_forwarded_unix
   2473 test_forwarded_rfc7239
   2474 test_forwarded_unix_rfc7239
   2475 test_paywall_disabled_endpoint
   2476 test_resource_budget_config
   2477 test_controlled_overload
   2478 test_graceful_shutdown
   2479 test_trusted_proxies_config
   2480 test_whitelist_config
   2481 
   2482 stop_paivana
   2483 
   2484 # --- Go upstream ------------------------------------------------------
   2485 if [ -n "$GO_PORT" ];
   2486 then
   2487     start_paivana "http://127.0.0.1:$GO_PORT"
   2488     run_battery "go"
   2489     test_pipelined
   2490     stop_paivana
   2491 fi
   2492 
   2493 # --- Python upstream --------------------------------------------------
   2494 if [ -n "$PY_PORT" ];
   2495 then
   2496     start_paivana "http://127.0.0.1:$PY_PORT"
   2497     run_battery "py"
   2498     test_pipelined
   2499     stop_paivana
   2500 fi
   2501 
   2502 # --- Rust upstream ----------------------------------------------------
   2503 if [ -n "$RS_PORT" ];
   2504 then
   2505     start_paivana "http://127.0.0.1:$RS_PORT"
   2506     run_battery "rs"
   2507     test_pipelined
   2508     stop_paivana
   2509 fi
   2510 
   2511 # --- Early-response upstream tests (restart paivana) ------------------
   2512 test_early_response
   2513 test_early_response_no_drain
   2514 
   2515 # --- Streaming (restarts paivana) -------------------------------------
   2516 test_streaming
   2517 
   2518 # --- Congestion and the memory bound (restarts paivana) ---------------
   2519 test_congestion
   2520 
   2521 # --- Truncated-response test (restarts paivana) -----------------------
   2522 test_short_body
   2523 
   2524 # --- Upstream-down test (runs last because it restarts paivana) -------
   2525 test_upstream_down
   2526 stop_paivana
   2527 
   2528 echo "=== all tests passed ==="
   2529 exit 0