paivana

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

test_reverse_proxy.sh (95545B)


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