#!/bin/bash # flaky-server - run an HTTP origin with request-selected delays and failures # # Usage: # flaky-server [port] # flaky-server -h # # Options: # -h, --help Show help message # # Paths: # /delay/, /drop/, /status/, /reset, /trickle/, / # /rate/; stream queries: bytes, chunk, type=text|xml|json # # Dependencies: # socat, sleep with fractional-second support _flaky_server() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="flaky-server" ;; esac local DEFAULT_PORT=8080 local MAX_PORT=65535 _error() { printf '[ERR][%s] %s\n' "$SCRIPT_NAME" "$*" >&2; } _info() { printf '[INF][%s] %s\n' "$SCRIPT_NAME" "$*" >&2; } _show_help() { local s="" local r="" if { [ -t 1 ] || [ -n "${CLICOLOR_FORCE:-}" ]; } && [ -z "${NO_COLOR:-}" ]; then s=$'\033[4m' r=$'\033[24m' fi cat < Wait, then return 200 with the millisecond value and a newline /drop/ Wait, send XML with Content-Length: 100000, then close early /status/ Immediately return a final HTTP status, 200-599 /reset Close after the request line without sending a response /trickle/ Send headers, then a body spread over the duration /rate/ Send headers, then a body at about bps bytes per second / Legacy delay; other paths ending in a number also work ms Decimal integer, 0-2147483647; leading zeroes are decimal bps Decimal integer, 1-1048576; bytes per second, not bits Trailing slashes and unrelated query keys are ignored. Invalid behavior values return 400; unknown paths return 404. Explicit behavior names take precedence over legacy numeric paths. STREAM QUERIES /trickle and /rate accept these optional query parameters; quote the URL bytes= Total body bytes including XML/JSON wrappers (1-1048576) chunk= Bytes per write; smaller writes are steadier, larger ones burst type= text (dots), xml (...), json ({"data":"..."}) Defaults: /trickle sends 10 text bytes, or 1024 for XML/JSON; /rate sends 10240 bytes. Both default to text and about ten writes, with chunk=ceil(bytes/10). Explicit chunk values are 1-1048576, with at most 1024 writes per response. XML needs at least 13 bytes, JSON 11. Invalid or duplicate controls return 400. Other behaviors ignore query parameters. EXAMPLES $SCRIPT_NAME 9000 curl -i http://localhost:9000/delay/2500 curl -i http://localhost:9000/drop/2000 curl -i http://localhost:9000/status/503 curl -i http://localhost:9000/reset curl -Ni http://localhost:9000/trickle/5000 curl -Ni http://localhost:9000/rate/1024 curl -N 'http://localhost:9000/trickle/5000?bytes=1024&chunk=128&type=json' curl -N 'http://localhost:9000/rate/1024?bytes=4096&chunk=2048&type=xml' curl http://localhost:9000/2500 curl -fsS https://toolio.sh/$SCRIPT_NAME | bash -s -- 9000 curl -H 'Content-Type: text/xml' --data '' http://localhost:9000/drop/2000 DEPENDENCIES socat, sleep (fractional seconds) EXIT STATUS 0 Help or clean listener exit 1 Listener or runtime failure 2 Invalid arguments 3 Missing dependency CAVEATS Plain HTTP only; terminate HTTPS at your tunnel. A proxy may buffer trickles or replace failures, so verify the observed result through the actual tunnel. /reset closes without a response; a TCP RST is not guaranteed. Streaming waits before each write, with a shorter final write when needed. Timing uses millisecond scheduling plus process overhead; rates are approximate. Writes are not TCP packet boundaries. Smaller chunks cost more overhead, and zero-length intervals do not sleep. This tests HTTP transport, not valid SOAP replies. POST bodies with Content-Length and Expect: 100-continue are accepted and discarded. Chunked request bodies return 501. HEAD sends headers only, so it cannot demonstrate a truncated or trickled body. Status 204, 205 and 304 have no body. Request lines and headers have an 8192-byte line limit, headers are limited to 100 lines, and request bodies to 1 MiB. Each request read has a 10-second timeout. Failed reads close the connection and log a diagnostic. Request logs go to stderr with a request ID, behavior, configured delay, result and elapsed seconds. Response headers include X-Request-ID. Request bodies, headers and query strings are not logged. Stop with Ctrl-C. Deliberate request failures do not stop the listener; active connections can finish after the listener stops. EOF } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } _parse_number() { local value="$1" local maximum="$2" local MAX_DECIMAL_DIGITS=10 case "$value" in ''|*[!0-9]*) return 1 ;; esac value="${value#"${value%%[!0]*}"}" value="${value:-0}" [ "${#value}" -le "$MAX_DECIMAL_DIGITS" ] || return 1 _number=$((10#$value)) [ "$_number" -le "$maximum" ] } # shellcheck disable=SC2329 # "This function is never invoked. Check usage (or ignored if invoked indirectly)." -- called by the exported socat handler _sleep_ms() { local seconds printf -v seconds '%d.%03d' "$(($1 / 1000))" "$(($1 % 1000))" sleep "$seconds" } # shellcheck disable=SC2329 # "This function is never invoked. Check usage (or ignored if invoked indirectly)." -- called by the exported socat handler _headers() { printf 'HTTP/1.1 %s %s\r\nContent-Type: %s\r\nConnection: close\r\nCache-Control: no-store\r\nX-Request-ID: %s\r\n' "$status" "$reason" "$content_type" "$request_id" case "$status" in 204|304) ;; *) printf 'Content-Length: %s\r\n' "$body_length" ;; esac printf '\r\n' } # Set the caller's stream settings and body after validating query controls # shellcheck disable=SC2329 # "This function is never invoked. Check usage (or ignored if invoked indirectly)." -- called by the exported socat handler _configure_stream() { local MAX_RESPONSE_BYTES=1048576 local MAX_STREAM_WRITES=1024 local DEFAULT_STREAM_WRITES=10 local DEFAULT_TRICKLE_BYTES=10 local DEFAULT_STRUCTURED_BYTES=1024 local DEFAULT_RATE_BYTES=10240 local bytes_seen="" local chunk_seen="" local type_seen="" local rest="$query" local pair local key local setting stream_type=text while [ -n "$rest" ]; do pair="${rest%%&*}" if [ "$rest" = "$pair" ]; then rest=""; else rest="${rest#*&}"; fi key="${pair%%=*}" setting="${pair#*=}" case "$key" in bytes|chunk) if [ "$pair" = "$key" ] || ! _parse_number "$setting" "$MAX_RESPONSE_BYTES" || [ "$_number" -eq 0 ]; then stream_error="invalid_$key"; return 1 fi if [ "$key" = bytes ]; then if [ -n "$bytes_seen" ]; then stream_error=duplicate_bytes; return 1; fi stream_bytes="$_number"; bytes_seen=1 else if [ -n "$chunk_seen" ]; then stream_error=duplicate_chunk; return 1; fi stream_chunk="$_number"; chunk_seen=1 fi ;; type) if [ -n "$type_seen" ]; then stream_error=duplicate_type; return 1; fi case "$setting" in text|xml|json) stream_type="$setting"; type_seen=1 ;; *) stream_error=invalid_type; return 1 ;; esac ;; esac done if [ -z "$bytes_seen" ]; then if [ "$mode" = rate ]; then stream_bytes="$DEFAULT_RATE_BYTES" elif [ "$stream_type" = text ]; then stream_bytes="$DEFAULT_TRICKLE_BYTES" else stream_bytes="$DEFAULT_STRUCTURED_BYTES" fi fi if [ -z "$chunk_seen" ]; then stream_chunk=$(((stream_bytes + DEFAULT_STREAM_WRITES - 1) / DEFAULT_STREAM_WRITES)) fi if [ "$(((stream_bytes + stream_chunk - 1) / stream_chunk))" -gt "$MAX_STREAM_WRITES" ]; then stream_error=too_many_chunks; return 1 fi local prefix="" local suffix="" case "$stream_type" in xml) prefix=''; suffix=''; content_type='application/xml; charset=utf-8' ;; json) prefix='{"data":"'; suffix='"}'; content_type='application/json' ;; esac local padding=$((stream_bytes - ${#prefix} - ${#suffix})) if [ "$padding" -lt 0 ]; then stream_error=body_too_small; return 1; fi local filler="." while [ "${#filler}" -lt "$padding" ]; do filler="$filler$filler"; done body="$prefix${filler:0:padding}$suffix" stream_duration_ms="$delay_ms" if [ "$mode" = rate ]; then stream_duration_ms=$(((stream_bytes * 1000 + rate_bytes_s - 1) / rate_bytes_s)) fi } # socat executes this exported function in an explicit Bash child # shellcheck disable=SC2329 # "This function is never invoked. Check usage (or ignored if invoked indirectly)." -- invoked by socat EXEC in a Bash child _flaky_response() { local MAX_DELAY_MS=2147483647 local MAX_LINE_BYTES=8192 local MAX_HEADERS=100 local MAX_BODY_BYTES=1048576 local READ_TIMEOUT_SECONDS=10 local READ_CHUNK_BYTES=4096 local DROP_LENGTH=100000 local MAX_RATE_BYTES_S=1048576 local request_id="$$" local SECONDS=0 local request local method local path local query="" local version local mode="delay" local value="" local delay_ms=0 local status=200 local reason="OK" local content_type="text/plain; charset=utf-8" local body="" local body_length=0 local stream_bytes=0 local stream_chunk=0 local stream_type="" local stream_duration_ms=0 local stream_error="" local rate_bytes_s=0 local _number if ! IFS= read -r -n "$MAX_LINE_BYTES" -t "$READ_TIMEOUT_SECONDS" request; then _error "event=read_failed id=$request_id phase=request_line elapsed_s=$SECONDS" return 0 fi request="${request%$'\r'}" IFS=' ' read -r method path version <<< "$request" if [ "${#request}" -ge "$MAX_LINE_BYTES" ] || ! [[ "$method" =~ ^[A-Z]+$ ]] || ! [[ "$version" =~ ^HTTP/1\.[01]$ ]] || [ "${path#/}" = "$path" ]; then status=400 method=INVALID fi case "$path" in *\?*) query="${path#*\?}"; path="${path%%\?*}" ;; esac while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do path="${path%/}"; done if [ "$status" -eq 200 ]; then case "$path" in /reset) _info "event=close id=$request_id method=$method mode=reset response_bytes=0 elapsed_s=$SECONDS" return 0 ;; /delay/*|/drop/*|/trickle/*|/rate/*|/status/*) mode="${path#/}"; mode="${mode%%/*}" value="${path#/*/}" ;; /delay|/drop|/trickle|/rate|/status|/reset/*) status=400 ;; *) value="${path##*/}" case "$value" in ''|*[!0-9]*) status=404 ;; esac ;; esac if [ "$status" -eq 200 ]; then if [ "$mode" = status ]; then if _parse_number "$value" 599 && [ "$_number" -ge 200 ]; then status="$_number" reason="Test status" else status=400 fi elif [ "$mode" = rate ]; then if _parse_number "$value" "$MAX_RATE_BYTES_S" && [ "$_number" -gt 0 ]; then rate_bytes_s="$_number" else status=400 fi elif _parse_number "$value" "$MAX_DELAY_MS"; then delay_ms="$_number" else status=400 fi fi fi if [ "$status" -eq 200 ] && { [ "$mode" = trickle ] || [ "$mode" = rate ]; }; then if ! _configure_stream; then _error "event=invalid_stream id=$request_id reason=$stream_error elapsed_s=$SECONDS" status=400 content_type="text/plain; charset=utf-8" fi fi local line local header_name local header_value local header_count=0 local content_length=0 local length_seen="" local expect_continue="" local reject_body="" local headers_done="" while IFS= read -r -n "$MAX_LINE_BYTES" -t "$READ_TIMEOUT_SECONDS" line; do if [ "$line" = $'\r' ] || [ -z "$line" ]; then headers_done=1; break; fi header_count=$((header_count + 1)) if [ "${#line}" -ge "$MAX_LINE_BYTES" ] || [ "$header_count" -gt "$MAX_HEADERS" ]; then _error "event=limit id=$request_id phase=headers elapsed_s=$SECONDS" return 0 fi line="${line%$'\r'}" header_name="$(printf '%s' "${line%%:*}" | tr '[:upper:]' '[:lower:]')" header_value="${line#*:}" header_value="${header_value#"${header_value%%[![:space:]]*}"}" header_value="${header_value%"${header_value##*[![:space:]]}"}" case "$header_name" in content-length) if [ -n "$length_seen" ] || ! _parse_number "$header_value" "$MAX_BODY_BYTES"; then _error "event=invalid_length id=$request_id max_bytes=$MAX_BODY_BYTES elapsed_s=$SECONDS" return 0 fi content_length="$_number"; length_seen=1 ;; transfer-encoding) status=501; reject_body=1 ;; expect) case "$header_value" in 100-continue) expect_continue=1 ;; *) status=417; reject_body=1 ;; esac ;; esac done if [ -z "$headers_done" ]; then _error "event=read_failed id=$request_id phase=headers elapsed_s=$SECONDS" return 0 fi # Discard framed uploads before closing so normal replies do not reset # a SOAP client's connection because unread request bytes remain local remaining="$content_length" local chunk_size local chunk local consumed if [ -z "$reject_body" ]; then if [ -n "$expect_continue" ] && [ "$remaining" -gt 0 ]; then printf 'HTTP/1.1 100 Continue\r\n\r\n' fi while [ "$remaining" -gt 0 ]; do chunk_size="$remaining" [ "$chunk_size" -le "$READ_CHUNK_BYTES" ] || chunk_size="$READ_CHUNK_BYTES" if ! IFS= read -r -d '' -n "$chunk_size" -t "$READ_TIMEOUT_SECONDS" chunk; then _error "event=read_failed id=$request_id phase=body remaining_bytes=$remaining elapsed_s=$SECONDS" return 0 fi consumed="${#chunk}" # read stops at NUL even with -n; count the discarded delimiter [ "$consumed" -eq "$chunk_size" ] || consumed=$((consumed + 1)) remaining=$((remaining - consumed)) done fi if [ "$status" -ne 200 ] || [ "$mode" = status ]; then mode=status delay_ms=0 content_type="text/plain; charset=utf-8" body="$status"$'\n' case "$status" in 204|205|304) body="" ;; 400) reason="Bad Request" ;; 404) reason="Not Found" ;; 417) reason="Expectation Failed" ;; 501) reason="Not Implemented" ;; esac elif [ "$mode" = drop ]; then content_type="text/xml; charset=utf-8" body='/dev/null 2>&1; then _error "$dependency is required" return 3 fi done export LC_ALL=C export SCRIPT_NAME export -f _error _info _parse_number _sleep_ms _headers _configure_stream _flaky_response _info "Starting server at http://localhost:$port (all IPv4 interfaces)" # nofork attaches the Bash handler directly to the accepted socket if ! socat "TCP4-LISTEN:$port,fork,reuseaddr" "EXEC:/bin/bash -c _flaky_response,nofork"; then _error "Failed to run listener on port $port" return 1 fi ) _flaky_server "$@" __flaky_server_rc=$? unset -f _flaky_server if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __flaky_server_rc; return $__flaky_server_rc" fi eval "unset __flaky_server_rc; exit $__flaky_server_rc"