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