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